├── index.js
├── Makefile
├── .github
└── workflows
│ ├── test.yaml
│ └── release.yaml
├── package.json
├── LICENSE
├── .gitignore
├── README.md
├── tests
└── lib
│ └── rules
│ └── vue-root-class.js
└── lib
└── rules
└── vue-root-class.js
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | module.exports = {
4 | rules: {
5 | 'vue-root-class': require('./lib/rules/vue-root-class')
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PROJECT_DIR := $(PWD)
2 | CURRENT_USER := $(shell id -u)
3 | CURRENT_GROUP := $(shell id -g)
4 |
5 | NODE_IMAGE := docker.io/node:14
6 |
7 | COMMAND := docker run --rm --user $(CURRENT_USER):$(CURRENT_GROUP) -v $(PROJECT_DIR):/app:delegated -w /app $(NODE_IMAGE)
8 |
9 | install:
10 | $(COMMAND) npm install
11 |
12 | test:
13 | $(COMMAND) npm test
14 |
15 | .PHONY: install test
16 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [10.x, 12.x, 14.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v1
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - run: npm ci
21 | - run: npm run build --if-present
22 | - run: npm test
23 | env:
24 | CI: true
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Verify and Publish
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | publish:
9 | name: Publish
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v2
14 | - name: Setup
15 | uses: actions/setup-node@v1
16 | with:
17 | node-version: '14.x'
18 | - name: Install
19 | run: npm ci
20 | - name: Test
21 | run: npm test
22 | - name: Publish
23 | run: |
24 | printf '//registry.npmjs.org/:_authToken=%s\n' '${{ secrets.NPM_TOKEN }}' > ~/.npmrc
25 | npm publish --access public
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint-plugin-vue-root-class",
3 | "version": "0.1.0",
4 | "description": "ESLint plugin to check vue components for a root class",
5 | "engines": {
6 | "node": ">=10.12.0"
7 | },
8 | "main": "index.js",
9 | "files": [
10 | "lib"
11 | ],
12 | "scripts": {
13 | "test": "standard && node tests/lib/rules/vue-root-class.js"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/wiese/eslint-plugin-vue-root-class.git"
18 | },
19 | "keywords": [
20 | "eslint",
21 | "eslint-plugin",
22 | "vue"
23 | ],
24 | "author": "wiese (https://github.com/wiese)",
25 | "license": "BSD-3-Clause",
26 | "bugs": {
27 | "url": "https://github.com/wiese/eslint-plugin-vue-root-class/issues"
28 | },
29 | "homepage": "https://github.com/wiese/eslint-plugin-vue-root-class#readme",
30 | "dependencies": {
31 | "vue-eslint-parser": "^7.1.1"
32 | },
33 | "devDependencies": {
34 | "standard": "^16.0.3"
35 | },
36 | "peerDependencies": {
37 | "eslint": "^7.0.0",
38 | "vue": "^2.0.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2020, wiese
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
106 | # IDE files
107 | .idea
108 | *.iml
109 | .project
110 | .settings
111 | .vscode
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # eslint-plugin-vue-root-class
2 |
3 | [](https://github.com/wiese/eslint-plugin-vue-root-class)
4 | [](https://www.npmjs.com/package/eslint-plugin-vue-root-class)
5 |
6 | Enforce a – configurable – class on [Vue](https://vuejs.org/) component root nodes.
7 |
8 | ## Motivation
9 |
10 | Consistently applying a class to components allows us to selectively administer styles and style resets/normalization (e.g. [1](https://meyerweb.com/eric/tools/css/reset/), [2](https://github.com/necolas/normalize.css)) to the matching elements and their children.
11 |
12 | ## Use
13 |
14 | ### Prerequisites
15 |
16 | This assumes you are already using eslint in your project.
17 |
18 | ### Installation
19 |
20 | * `npm install --save-dev eslint-plugin-vue-root-class`
21 | * Mend the [eslint configuration](https://eslint.org/docs/user-guide/configuring#configuring-plugins) of your project (e.g. `.eslintrc.js`) to contain
22 | ```
23 | {
24 | // ...
25 | plugins: [
26 | 'vue-root-class'
27 | ],
28 | rules: {
29 | 'vue-root-class/vue-root-class': [ 'error', { class: 'fancy' } ]
30 | }
31 | }
32 | ```
33 |
34 | 🛈 Configuring the relevant class ("fancy" in the above example) is mandatory
35 |
36 | ## Known limitations
37 |
38 | This only works…
39 |
40 | * with classes added through a vanilla class attribute (e.g. `class="foo"`) or [bound](https://vuejs.org/v2/guide/class-and-style.html) through an array as a literal (e.g. `:class="[ 'foo' ]"`)
41 | 💡 Eslint performs [static analysis](https://en.wikipedia.org/wiki/Static_program_analysis) of your component source code, it does not run it. As a consequence it can only detect [literal values](https://en.wikipedia.org/wiki/Literal_(computer_programming)), not values which would only be determined at [runtime](https://en.wikipedia.org/wiki/Runtime_(program_lifecycle_phase)).
42 | * if the relevant class is an attribute to the first element inside of the template (ignores possible vue-magic [v-if, ...])
43 | 💡 Inside `eslint-plugin-vue` there [have](https://github.com/vuejs/eslint-plugin-vue/issues/884) [been](https://github.com/vuejs/eslint-plugin-vue/issues/986) [attempts](https://github.com/vuejs/eslint-plugin-vue/issues/971) to cater to a more liberal iterpretation of a template ["root element"](https://vuejs.org/v2/guide/components.html#A-Single-Root-Element) but at this time there is no known need for the resulting complexity here.
44 |
--------------------------------------------------------------------------------
/tests/lib/rules/vue-root-class.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const RuleTester = require('eslint').RuleTester
4 | const rule = require('../../../lib/rules/vue-root-class')
5 |
6 | const tester = new RuleTester({
7 | parser: require.resolve('vue-eslint-parser'),
8 | parserOptions: { ecmaVersion: 2015 }
9 | })
10 |
11 | const TEST_CLASS = 'rootclass'
12 |
13 | tester.run('vue-root-class', rule, {
14 | valid: [
15 | {
16 | filename: 'Foo.vue',
17 | code: 'abc
',
18 | options: [{ class: TEST_CLASS }]
19 | },
20 | {
21 | filename: 'Foo.vue',
22 | code: 'Foo
',
23 | options: [{ class: TEST_CLASS }]
24 | },
25 | {
26 | filename: 'Foo.vue',
27 | code: '\n \n abc
\n',
28 | options: [{ class: TEST_CLASS }]
29 | }
30 | ],
31 | invalid: [
32 | {
33 | filename: 'FooBar.vue',
34 | code: '\n',
35 | errors: [
36 | 'Components must have a root node which bears the "' + TEST_CLASS + '" class.'
37 | ],
38 | options: [{ class: TEST_CLASS }]
39 | },
40 | {
41 | filename: 'FooBar.vue',
42 | code: '\n',
43 | errors: [
44 | 'Components must have a root node which bears the "' + TEST_CLASS + '" class.'
45 | ],
46 | options: [{ class: TEST_CLASS }]
47 | },
48 | {
49 | filename: 'FooBar.vue',
50 | code: 'nope
',
51 | errors: [
52 | 'Components must bear the "' + TEST_CLASS + '" class in their root node.'
53 | ],
54 | options: [{ class: TEST_CLASS }]
55 | },
56 | {
57 | filename: 'FooBar.vue',
58 | code: 'one
two
',
59 | errors: [
60 | // The rule only supports checking the first element for the class
61 | 'Components must bear the "' + TEST_CLASS + '" class in their root node.'
62 | ],
63 | options: [{ class: TEST_CLASS }]
64 | },
65 | {
66 | filename: 'FooBar.vue',
67 | code: 'nope
',
68 | errors: [
69 | // Adding the root class through an ObjectExpressions is not supported.
70 | 'Components must bear the "' + TEST_CLASS + '" class in their root node.'
71 | ],
72 | options: [{ class: TEST_CLASS }]
73 | }
74 | ]
75 | })
76 |
--------------------------------------------------------------------------------
/lib/rules/vue-root-class.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function getFirstElement (elements) {
4 | for (const child of elements) {
5 | if (child.type === 'VElement') {
6 | return child
7 | }
8 | }
9 | return null
10 | }
11 |
12 | module.exports = {
13 | meta: {
14 | type: 'problem',
15 | docs: {
16 | description: 'Enforce a - configurable - class on vue component root nodes.',
17 | category: 'essential'
18 | },
19 | schema: [
20 | {
21 | type: 'object',
22 | properties: {
23 | class: {
24 | type: 'string'
25 | }
26 | }
27 | }
28 | ],
29 | messages: {
30 | classMissing: 'Components must bear the "{{requiredClass}}" class in their root node.',
31 | rootMissing: 'Components must have a root node which bears the "{{requiredClass}}" class.'
32 | }
33 | },
34 |
35 | create (context) {
36 | return {
37 | Program (program) {
38 | const requiredClass = context.options[0] && context.options[0].class
39 |
40 | if (!requiredClass) {
41 | throw new Error('vue-root-class rule must have "class" option configured!')
42 | }
43 |
44 | const classes = new Set()
45 |
46 | const element = program.templateBody
47 | if (!element) {
48 | return
49 | }
50 | const rootNode = getFirstElement(element.children)
51 | if (!rootNode) {
52 | // maybe we should entrust this to other (vue) linting rules
53 | context.report({
54 | node: element.startTag,
55 | loc: element.startTag.loc,
56 | messageId: 'rootMissing',
57 | data: {
58 | requiredClass
59 | }
60 | })
61 | return
62 | }
63 |
64 | rootNode.startTag.attributes.forEach((attribute) => {
65 | if (attribute.key.name === 'class') { // a vanilla class attribute
66 | if (attribute.value.type !== 'VLiteral') {
67 | throw new Error('Expecting "class" values to be literals')
68 | }
69 | attribute.value.value.split(' ') // TODO support more white spaces
70 | .forEach(classes.add, classes)
71 | } else if ( // bound class information
72 | attribute.key.type === 'VDirectiveKey' &&
73 | attribute.key.argument &&
74 | attribute.key.argument.type === 'VIdentifier' &&
75 | attribute.key.argument.name === 'class'
76 | ) {
77 | if (
78 | attribute.value.type === 'VExpressionContainer' &&
79 | attribute.value.expression.type === 'ArrayExpression' // ignores ObjectExpressions
80 | ) {
81 | attribute.value.expression.elements.forEach((node) => {
82 | if (node.type === 'Literal') {
83 | classes.add(node.value)
84 | }
85 | })
86 | }
87 | }
88 | })
89 |
90 | if (!classes.has(requiredClass)) {
91 | context.report({
92 | node: rootNode.startTag,
93 | loc: rootNode.startTag.loc,
94 | messageId: 'classMissing',
95 | data: {
96 | requiredClass
97 | }
98 | // auto-fixing this is thinkable but messy
99 | })
100 | }
101 | }
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------