├── 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 | [![CI](https://github.com/wiese/eslint-plugin-vue-root-class/workflows/Node.js%20CI/badge.svg)](https://github.com/wiese/eslint-plugin-vue-root-class) 4 | [![npm version](https://img.shields.io/npm/v/eslint-plugin-vue-root-class.svg)](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: '', 18 | options: [{ class: TEST_CLASS }] 19 | }, 20 | { 21 | filename: 'Foo.vue', 22 | code: '', 23 | options: [{ class: TEST_CLASS }] 24 | }, 25 | { 26 | filename: 'Foo.vue', 27 | code: '', 28 | options: [{ class: TEST_CLASS }] 29 | } 30 | ], 31 | invalid: [ 32 | { 33 | filename: 'FooBar.vue', 34 | code: '', 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: '', 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: '', 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: '', 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: '', 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 | --------------------------------------------------------------------------------