@master/eslint-plugin-css 2.0.0-beta.175

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ <br>
2
+ <div align="center">
3
+
4
+ <p align="center">
5
+ <a href="https://css.master.co">
6
+ <picture>
7
+ <source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/33840671/201701649-3bb7d698-abec-4d5f-ac30-ccc4d7bafcd4.svg">
8
+ <source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/33840671/201703010-77bf2373-9899-40cc-98f5-30cf9b546941.svg">
9
+ <img alt="Master CSS" src="https://user-images.githubusercontent.com/33840671/201703010-77bf2373-9899-40cc-98f5-30cf9b546941.svg" width="100%">
10
+ </picture>
11
+ </a>
12
+ </p>
13
+ <p align="center">An ESLint plugin enforcing a consistent coding style for Master CSS</p>
14
+
15
+ <p align="center">
16
+ <a aria-label="GitHub release (latest by date including pre-releases)" href="https://github.com/master-co/css/releases">
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/github/v/release/master-co/css?include_prereleases&color=212022&label=&style=for-the-badge&logo=github&logoColor=fff">
19
+ <source media="(prefers-color-scheme: light)" srcset="https://img.shields.io/github/v/release/master-co/css?include_prereleases&color=f6f7f8&label=&style=for-the-badge&logo=github&logoColor=%23000">
20
+ <img alt="NPM Version" src="https://img.shields.io/github/v/release/master-co/css?include_prereleases&color=f6f7f8&label=&style=for-the-badge&logo=github">
21
+ </picture>
22
+ </a>
23
+ <a aria-label="NPM Package" href="https://www.npmjs.com/package/@master/eslint-plugin-css">
24
+ <picture>
25
+ <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/npm/dm/@master/eslint-plugin-css?color=212022&label=%20&logo=npm&style=for-the-badge">
26
+ <source media="(prefers-color-scheme: light)" srcset="https://img.shields.io/npm/dm/@master/eslint-plugin-css?color=f6f7f8&label=%20&logo=npm&style=for-the-badge">
27
+ <img alt="NPM package ( download / month )" src="https://img.shields.io/npm/dm/@master/eslint-plugin-css?color=f6f7f8&label=%20&logo=npm&style=for-the-badge">
28
+ </picture>
29
+ </a>
30
+ <a aria-label="Discord Community" href="https://discord.gg/sZNKpAAAw6">
31
+ <picture>
32
+ <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/discord/917780624314613760?color=212022&label=%20&logo=discord&style=for-the-badge">
33
+ <source media="(prefers-color-scheme: light)" srcset="https://img.shields.io/discord/917780624314613760?color=f6f7f8&label=%20&logo=discord&style=for-the-badge">
34
+ <img alt="Discord online" src="https://img.shields.io/discord/917780624314613760?color=f6f7f8&label=%20&logo=discord&style=for-the-badge">
35
+ </picture>
36
+ </a>
37
+ <a aria-label="Follow @mastercorg" href="https://twitter.com/mastercorg">
38
+ <picture>
39
+ <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/static/v1?label=%20&message=twitter&color=212022&logo=twitter&style=for-the-badge">
40
+ <source media="(prefers-color-scheme: light)" srcset="https://img.shields.io/static/v1?label=%20&message=twitter&color=f6f7f8&logo=twitter&style=for-the-badge">
41
+ <img alt="Follow @mastercorg" src="https://img.shields.io/static/v1?label=%20&message=twitter&color=f6f7f8&logo=twitter&style=for-the-badge">
42
+ </picture>
43
+ </a>
44
+ <a aria-label="Github Actions" href="https://github.com/1aron/repo/actions/workflows/release.yml">
45
+ <picture>
46
+ <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/github/actions/workflow/status/master-co/css/release.yml?branch=beta&label=%20&message=twitter&color=212022&logo=githubactions&style=for-the-badge">
47
+ <source media="(prefers-color-scheme: light)" srcset="https://img.shields.io/github/actions/workflow/status/master-co/css/release.yml?branch=beta&label=%20&message=twitter&color=f6f7f8&logo=githubactions&style=for-the-badge&logoColor=%23000">
48
+ <img alt="Github release actions" src="https://img.shields.io/github/actions/workflow/status/master-co/css/release.yml?branch=beta&label=%20&message=twitter&color=f6f7f8&logo=githubactions&style=for-the-badge&logoColor=%23000">
49
+ </picture>
50
+ </a>
51
+ </p>
52
+
53
+ </div>
54
+
55
+ ## Documentation
56
+ Check out the official [documentation](https://beta.css.master.co/docs/code-linting).
57
+
58
+ ## Credits
59
+ This plugin is heavily inspired by the awesome [eslint-plugin-tailwindcss](https://github.com/francoismassart/eslint-plugin-tailwindcss). We want to thank for their initial work, which served as the foundation for this project. While significant modifications have been made, the core ideas and concepts from A Plugin have been instrumental in developing this plugin.
package/lib/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @fileoverview Rules enforcing best practices while using Master CSS
3
+ * @author Miles
4
+ */
5
+ 'use strict'
6
+
7
+ //------------------------------------------------------------------------------
8
+ // Requirements
9
+ //------------------------------------------------------------------------------
10
+
11
+ const requireIndex = require('requireindex')
12
+
13
+ //------------------------------------------------------------------------------
14
+ // Plugin Definition
15
+ //------------------------------------------------------------------------------
16
+
17
+
18
+ // import all rules in lib/rules
19
+ module.exports.rules = requireIndex(__dirname + '/rules')
@@ -0,0 +1,273 @@
1
+ /* eslint-disable no-case-declarations */
2
+ /**
3
+ * @fileoverview Use a consistent orders for the Master CSS classnames, based on property then on variants
4
+ * @author Miles
5
+ */
6
+ 'use strict'
7
+
8
+ // Modified from https://github.com/francoismassart/eslint-plugin-tailwindcss
9
+
10
+ const docsUrl = require('../util/docsUrl')
11
+ const astUtil = require('../util/ast')
12
+ const removeDuplicatesFromClassnamesAndWhitespaces = require('../util/removeDuplicatesFromClassnamesAndWhitespaces')
13
+ const getOption = require('../util/settings')
14
+ const parserUtil = require('../util/parser')
15
+
16
+ const MasterCSS = require('@master/css').MasterCSS
17
+
18
+ //------------------------------------------------------------------------------
19
+ // Rule Definition
20
+ //------------------------------------------------------------------------------
21
+
22
+ // Predefine message for use in context.report conditional.
23
+ // messageId will still be usable in tests.
24
+ const INVALID_CLASSNAMES_ORDER_MSG = 'Invalid Master CSS classnames order'
25
+
26
+ module.exports = {
27
+ meta: {
28
+ docs: {
29
+ description: 'Enforce a consistent and logical order of the Master CSS classnames',
30
+ category: 'Stylistic Issues',
31
+ recommended: false,
32
+ url: docsUrl('class-order'),
33
+ },
34
+ messages: {
35
+ invalidOrder: INVALID_CLASSNAMES_ORDER_MSG,
36
+ },
37
+ fixable: 'code',
38
+ schema: [
39
+ {
40
+ type: 'object',
41
+ properties: {
42
+ callees: {
43
+ type: 'array',
44
+ items: { type: 'string', minLength: 0 },
45
+ uniqueItems: true,
46
+ },
47
+ ignoredKeys: {
48
+ type: 'array',
49
+ items: { type: 'string', minLength: 0 },
50
+ uniqueItems: true,
51
+ },
52
+ config: {
53
+ // returned from `loadConfig()` utility
54
+ type: ['string', 'object'],
55
+ },
56
+ removeDuplicates: {
57
+ // default: true,
58
+ type: 'boolean',
59
+ },
60
+ tags: {
61
+ type: 'array',
62
+ items: { type: 'string', minLength: 0 },
63
+ uniqueItems: true,
64
+ },
65
+ },
66
+ },
67
+ ],
68
+ },
69
+
70
+ create: function (context) {
71
+ const callees = getOption(context, 'callees')
72
+ const skipClassAttribute = getOption(context, 'skipClassAttribute')
73
+ const tags = getOption(context, 'tags')
74
+ const masterCssConfig = getOption(context, 'config')
75
+ const classRegex = getOption(context, 'classRegex')
76
+
77
+ const masterCss = new MasterCSS(masterCssConfig)
78
+
79
+ //----------------------------------------------------------------------
80
+ // Helpers
81
+ //----------------------------------------------------------------------
82
+ /**
83
+ * Recursive function crawling into child nodes
84
+ * @param {ASTNode} node The root node of the current parsing
85
+ * @param {ASTNode} arg The child node of node
86
+ * @returns {void}
87
+ */
88
+ const sortNodeArgumentValue = (node, arg = null) => {
89
+ let originalClassNamesValue = null
90
+ let start = null
91
+ let end = null
92
+ let prefix = ''
93
+ let suffix = ''
94
+ if (arg === null) {
95
+ originalClassNamesValue = astUtil.extractValueFromNode(node)
96
+ const range = astUtil.extractRangeFromNode(node)
97
+ if (node.type === 'TextAttribute') {
98
+ start = range[0]
99
+ end = range[1]
100
+ } else {
101
+ start = range[0] + 1
102
+ end = range[1] - 1
103
+ }
104
+ } else {
105
+ switch (arg.type) {
106
+ case 'Identifier':
107
+ return
108
+ case 'TemplateLiteral':
109
+ arg.expressions.forEach((exp) => {
110
+ sortNodeArgumentValue(node, exp)
111
+ })
112
+ arg.quasis.forEach((quasis) => {
113
+ sortNodeArgumentValue(node, quasis)
114
+ })
115
+ return
116
+ case 'ConditionalExpression':
117
+ sortNodeArgumentValue(node, arg.consequent)
118
+ sortNodeArgumentValue(node, arg.alternate)
119
+ return
120
+ case 'LogicalExpression':
121
+ sortNodeArgumentValue(node, arg.right)
122
+ return
123
+ case 'ArrayExpression':
124
+ arg.elements.forEach((el) => {
125
+ sortNodeArgumentValue(node, el)
126
+ })
127
+ return
128
+ case 'ObjectExpression':
129
+ const isUsedByClassNamesPlugin = node.callee && node.callee.name === 'classnames'
130
+ const isVue = node.key && node.key.type === 'VDirectiveKey'
131
+ arg.properties.forEach((prop) => {
132
+ const propVal = isUsedByClassNamesPlugin || isVue ? prop.key : prop.value
133
+ sortNodeArgumentValue(node, propVal)
134
+ })
135
+ return
136
+ case 'Property':
137
+ sortNodeArgumentValue(node, arg.key)
138
+ break
139
+ case 'Literal':
140
+ originalClassNamesValue = arg.value
141
+ start = arg.range[0] + 1
142
+ end = arg.range[1] - 1
143
+ break
144
+ case 'TemplateElement':
145
+ originalClassNamesValue = arg.value.raw
146
+ if (originalClassNamesValue === '') {
147
+ return
148
+ }
149
+ start = arg.range[0]
150
+ end = arg.range[1]
151
+ // https://github.com/eslint/eslint/issues/13360
152
+ // The problem is that range computation includes the backticks (`test`)
153
+ // but value.raw does not include them, so there is a mismatch.
154
+ // start/end does not include the backticks, therefore it matches value.raw.
155
+ const txt = context.getSourceCode().getText(arg)
156
+ prefix = astUtil.getTemplateElementPrefix(txt, originalClassNamesValue)
157
+ suffix = astUtil.getTemplateElementSuffix(txt, originalClassNamesValue)
158
+ originalClassNamesValue = astUtil.getTemplateElementBody(txt, prefix, suffix)
159
+ break
160
+ }
161
+ }
162
+
163
+ let { classNames, whitespaces, headSpace, tailSpace } =
164
+ astUtil.extractClassnamesFromValue(originalClassNamesValue)
165
+
166
+ if (classNames.length <= 1) {
167
+ // Don't run sorting for a single or empty className
168
+ return
169
+ }
170
+
171
+ for (const className of classNames) {
172
+ masterCss.insert(className)
173
+ }
174
+
175
+ let orderedClassNames = masterCss.rules
176
+ .filter(x => classNames.includes(x.className))
177
+ .map(x => x.className)
178
+
179
+ orderedClassNames = orderedClassNames.concat(classNames.filter(x => !orderedClassNames.includes(x)))
180
+
181
+ removeDuplicatesFromClassnamesAndWhitespaces(orderedClassNames, whitespaces, headSpace, tailSpace)
182
+
183
+ // Generates the validated/sorted attribute value
184
+ let validatedClassNamesValue = ''
185
+ for (let i = 0; i < orderedClassNames.length; i++) {
186
+ const w = whitespaces[i] ?? ''
187
+ const cls = orderedClassNames[i]
188
+ validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`
189
+ if (headSpace && tailSpace && i === orderedClassNames.length - 1) {
190
+ validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? ''
191
+ }
192
+ }
193
+
194
+ if (originalClassNamesValue !== validatedClassNamesValue) {
195
+ validatedClassNamesValue = prefix + validatedClassNamesValue + suffix
196
+ context.report({
197
+ node: node,
198
+ messageId: 'invalidOrder',
199
+ fix: function (fixer) {
200
+ return fixer.replaceTextRange([start, end], validatedClassNamesValue)
201
+ },
202
+ })
203
+ }
204
+ }
205
+
206
+ //----------------------------------------------------------------------
207
+ // Public
208
+ //----------------------------------------------------------------------
209
+
210
+ const attributeVisitor = function (node) {
211
+ if (!astUtil.isClassAttribute(node, classRegex) || skipClassAttribute) {
212
+ return
213
+ }
214
+ if (astUtil.isLiteralAttributeValue(node)) {
215
+ sortNodeArgumentValue(node)
216
+ } else if (node.value && node.value.type === 'JSXExpressionContainer') {
217
+ sortNodeArgumentValue(node, node.value.expression)
218
+ }
219
+ }
220
+
221
+ const callExpressionVisitor = function (node) {
222
+ const calleeStr = astUtil.calleeToString(node.callee)
223
+ if (callees.findIndex((name) => calleeStr === name) === -1) {
224
+ return
225
+ }
226
+
227
+ node.arguments.forEach((arg) => {
228
+ sortNodeArgumentValue(node, arg)
229
+ })
230
+ }
231
+
232
+ const scriptVisitor = {
233
+ JSXAttribute: attributeVisitor,
234
+ TextAttribute: attributeVisitor,
235
+ CallExpression: callExpressionVisitor,
236
+ TaggedTemplateExpression: function (node) {
237
+ if (!tags.includes(node.tag.name)) {
238
+ return
239
+ }
240
+
241
+ sortNodeArgumentValue(node, node.quasi)
242
+ },
243
+ }
244
+ const templateVisitor = {
245
+ CallExpression: callExpressionVisitor,
246
+ /*
247
+ Tagged templates inside data bindings
248
+ https://github.com/vuejs/vue/issues/9721
249
+ */
250
+ VAttribute: function (node) {
251
+ switch (true) {
252
+ case !astUtil.isValidVueAttribute(node, classRegex):
253
+ return
254
+ case astUtil.isVLiteralValue(node):
255
+ sortNodeArgumentValue(node, null)
256
+ break
257
+ case astUtil.isArrayExpression(node):
258
+ node.value.expression.elements.forEach((arg) => {
259
+ sortNodeArgumentValue(node, arg)
260
+ })
261
+ break
262
+ case astUtil.isObjectExpression(node):
263
+ node.value.expression.properties.forEach((prop) => {
264
+ sortNodeArgumentValue(node, prop)
265
+ })
266
+ break
267
+ }
268
+ },
269
+ }
270
+
271
+ return parserUtil.defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor)
272
+ },
273
+ }
@@ -0,0 +1,371 @@
1
+ /**
2
+ * @fileoverview Utility functions for AST
3
+ */
4
+
5
+ 'use strict'
6
+
7
+ const { separatorRegEx } = require('./regex')
8
+ // context.parserPath
9
+ // /.../eslint-plugin-tailwindcss/node_modules/espree/espree.js
10
+ // /.../eslint-plugin-tailwindcss/node_modules/@angular-eslint/template-parser/dist/index.js
11
+
12
+ const removeDuplicatesFromArray = require('./removeDuplicatesFromArray')
13
+
14
+ function calleeToString(calleeNode) {
15
+ if (calleeNode.type === 'Identifier') {
16
+ return calleeNode.name
17
+ }
18
+ if (calleeNode.type === 'MemberExpression') {
19
+ return `${calleeNode.object.name}.${calleeNode.property.name}`
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Find out if node is `class` or `className`
25
+ *
26
+ * @param {ASTNode} node The AST node being checked
27
+ * @param {String} classRegex Regex to test the attribute that is being checked against
28
+ * @returns {Boolean}
29
+ */
30
+ function isClassAttribute(node, classRegex) {
31
+ if (!node.name) {
32
+ return false
33
+ }
34
+ let name = ''
35
+ switch (node.type) {
36
+ case 'TextAttribute':
37
+ name = node.name
38
+ break
39
+ default:
40
+ name = node.name.name
41
+ }
42
+ return new RegExp(classRegex).test(name)
43
+ }
44
+
45
+ /**
46
+ * Find out if node is `class`
47
+ *
48
+ * @param {ASTNode} node The AST node being checked
49
+ * @param {String} classRegex Regex to test the attribute that is being checked against
50
+ * @returns {Boolean}
51
+ */
52
+ function isVueClassAttribute(node, classRegex) {
53
+ const re = new RegExp(classRegex)
54
+ let name = ''
55
+ switch (true) {
56
+ case node.key && node.key.name && re.test(node.key.name):
57
+ // class="vue-classes-as-litteral"
58
+ return true
59
+ case node.key &&
60
+ node.key.name &&
61
+ node.key.name.name &&
62
+ node.key.argument &&
63
+ node.key.argument.name &&
64
+ /^bind$/.test(node.key.name.name) &&
65
+ re.test(node.key.argument.name):
66
+ // v-bind:class="vue-classes-as-bind"
67
+ // :class="vue-classes-as-bind"
68
+ return true
69
+ default:
70
+ return false
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Find out if node's value attribute is just simple text
76
+ *
77
+ * @param {ASTNode} node The AST node being checked
78
+ * @returns {Boolean}
79
+ */
80
+ function isVLiteralValue(node) {
81
+ return node.value && node.value.type === 'VLiteral'
82
+ }
83
+
84
+ /**
85
+ * Find out if node's value attribute is an ArrayExpression
86
+ *
87
+ * @param {ASTNode} node The AST node being checked
88
+ * @returns {Boolean}
89
+ */
90
+ function isArrayExpression(node) {
91
+ return node.value && node.value.type === 'VExpressionContainer' && node.value.expression.type === 'ArrayExpression'
92
+ }
93
+
94
+ /**
95
+ * Find out if node's value attribute is an ObjectExpression
96
+ *
97
+ * @param {ASTNode} node The AST node being checked
98
+ * @returns {Boolean}
99
+ */
100
+ function isObjectExpression(node) {
101
+ return node.value && node.value.type === 'VExpressionContainer' && node.value.expression.type === 'ObjectExpression'
102
+ }
103
+
104
+ /**
105
+ * Find out if node's value attribute is just simple text
106
+ *
107
+ * @param {ASTNode} node The AST node being checked
108
+ * @returns {Boolean}
109
+ */
110
+ function isVueValidAttributeValue(node) {
111
+ switch (true) {
112
+ case isVLiteralValue(node): // Simple string
113
+ case isArrayExpression(node): // ['tw-unknown-class']
114
+ case isObjectExpression(node): // {'tw-unknown-class': true}
115
+ return true
116
+ default:
117
+ return false
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Find out if node's value attribute is just simple text
123
+ *
124
+ * @param {ASTNode} node The AST node being checked
125
+ * @returns {Boolean}
126
+ */
127
+ function isLiteralAttributeValue(node) {
128
+ if (node.type === 'TextAttribute' && node.name === 'class' && typeof node.value === 'string') {
129
+ return true
130
+ }
131
+ if (node.value) {
132
+ switch (node.value.type) {
133
+ case 'Literal':
134
+ // No support for dynamic or conditional...
135
+ return !/\{|\?|\}/.test(node.value.value)
136
+ case 'JSXExpressionContainer':
137
+ // className={"..."}
138
+ return node.value.expression.type === 'Literal'
139
+ }
140
+ }
141
+ return false
142
+ }
143
+
144
+ /**
145
+ * Find out if the node is a valid candidate for our rules
146
+ *
147
+ * @param {ASTNode} node The AST node being checked
148
+ * @param {String} classRegex Regex to test the attribute that is being checked against
149
+ * @returns {Boolean}
150
+ */
151
+ function isValidJSXAttribute(node, classRegex) {
152
+ if (!isClassAttribute(node, classRegex)) {
153
+ // Only run for class[Name] attributes
154
+ return false
155
+ }
156
+ if (!isLiteralAttributeValue(node)) {
157
+ // No support for dynamic or conditional classnames
158
+ return false
159
+ }
160
+ return true
161
+ }
162
+
163
+ /**
164
+ * Find out if the node is a valid candidate for our rules
165
+ *
166
+ * @param {ASTNode} node The AST node being checked
167
+ * @param {String} classRegex Regex to test the attribute that is being checked against
168
+ * @returns {Boolean}
169
+ */
170
+ function isValidVueAttribute(node, classRegex) {
171
+ if (!isVueClassAttribute(node, classRegex)) {
172
+ // Only run for class attributes
173
+ return false
174
+ }
175
+ if (!isVueValidAttributeValue(node)) {
176
+ // No support for dynamic or conditional classnames
177
+ return false
178
+ }
179
+ return true
180
+ }
181
+
182
+ function extractRangeFromNode(node) {
183
+ if (node.type === 'TextAttribute' && node.name === 'class') {
184
+ return [node.valueSpan.fullStart.offset, node.valueSpan.end.offset]
185
+ }
186
+ switch (node.value.type) {
187
+ case 'JSXExpressionContainer':
188
+ return node.value.expression.range
189
+ default:
190
+ return node.value.range
191
+ }
192
+ }
193
+
194
+ function extractValueFromNode(node) {
195
+ if (node.type === 'TextAttribute' && node.name === 'class') {
196
+ return node.value
197
+ }
198
+ switch (node.value.type) {
199
+ case 'JSXExpressionContainer':
200
+ return node.value.expression.value
201
+ case 'VExpressionContainer':
202
+ switch (node.value.expression.type) {
203
+ case 'ArrayExpression':
204
+ return node.value.expression.elements
205
+ case 'ObjectExpression':
206
+ return node.value.expression.properties
207
+ }
208
+ return node.value.expression.value
209
+ default:
210
+ return node.value.value
211
+ }
212
+ }
213
+
214
+ function extractClassnamesFromValue(classStr) {
215
+ if (typeof classStr !== 'string') {
216
+ return { classNames: [], whitespaces: [], headSpace: false, tailSpace: false }
217
+ }
218
+ let parts = classStr.split(separatorRegEx)
219
+ if (parts[0] === '') {
220
+ parts.shift()
221
+ }
222
+ if (parts[parts.length - 1] === '') {
223
+ parts.pop()
224
+ }
225
+ let headSpace = separatorRegEx.test(parts[0])
226
+ let tailSpace = separatorRegEx.test(parts[parts.length - 1])
227
+ const isClass = (_, i) => (headSpace ? i % 2 !== 0 : i % 2 === 0)
228
+ const isNotClass = (_, i) => (headSpace ? i % 2 === 0 : i % 2 !== 0)
229
+ let classNames = parts.filter(isClass)
230
+ let whitespaces = parts.filter(isNotClass)
231
+ return { classNames: classNames, whitespaces: whitespaces, headSpace: headSpace, tailSpace: tailSpace }
232
+ }
233
+
234
+ /**
235
+ * Inspect and parse an abstract syntax node and run a callback function
236
+ *
237
+ * @param {ASTNode} rootNode The current root node being parsed by eslint
238
+ * @param {ASTNode} childNode The AST node child argument being checked
239
+ * @param {Function} cb The callback function
240
+ * @param {Boolean} skipConditional Optional, indicate distinct parsing for conditional nodes
241
+ * @param {Boolean} isolate Optional, set internally to isolate parsing and validation on conditional children
242
+ * @param {Array} ignoredKeys Optional, set object keys which should not be parsed e.g. for `cva`
243
+ * @returns {void}
244
+ */
245
+ function parseNodeRecursive(rootNode, childNode, cb, skipConditional = false, isolate = false, ignoredKeys = []) {
246
+ // TODO allow vue non litteral
247
+ let originalClassNamesValue
248
+ let classNames
249
+ if (childNode === null) {
250
+ originalClassNamesValue = extractValueFromNode(rootNode);
251
+ ({ classNames } = extractClassnamesFromValue(originalClassNamesValue))
252
+ classNames = removeDuplicatesFromArray(classNames)
253
+ if (classNames.length === 0) {
254
+ // Don't run for empty className
255
+ return
256
+ }
257
+ cb(classNames, rootNode)
258
+ } else if (childNode === undefined) {
259
+ // Ignore invalid child candidates (probably inside complex TemplateLiteral)
260
+ return
261
+ } else {
262
+ const forceIsolation = skipConditional ? true : isolate
263
+ let trim = false
264
+ switch (childNode.type) {
265
+ case 'TemplateLiteral':
266
+ childNode.expressions.forEach((exp) => {
267
+ parseNodeRecursive(rootNode, exp, cb, skipConditional, forceIsolation, ignoredKeys)
268
+ })
269
+ childNode.quasis.forEach((quasis) => {
270
+ parseNodeRecursive(rootNode, quasis, cb, skipConditional, isolate, ignoredKeys)
271
+ })
272
+ return
273
+ case 'ConditionalExpression':
274
+ parseNodeRecursive(rootNode, childNode.consequent, cb, skipConditional, forceIsolation, ignoredKeys)
275
+ parseNodeRecursive(rootNode, childNode.alternate, cb, skipConditional, forceIsolation, ignoredKeys)
276
+ return
277
+ case 'LogicalExpression':
278
+ parseNodeRecursive(rootNode, childNode.right, cb, skipConditional, forceIsolation, ignoredKeys)
279
+ return
280
+ case 'ArrayExpression':
281
+ childNode.elements.forEach((el) => {
282
+ parseNodeRecursive(rootNode, el, cb, skipConditional, forceIsolation, ignoredKeys)
283
+ })
284
+ return
285
+ case 'ObjectExpression':
286
+ childNode.properties.forEach((prop) => {
287
+ const isUsedByClassNamesPlugin = rootNode.callee && rootNode.callee.name === 'classnames'
288
+
289
+ if (prop.type === 'SpreadElement') {
290
+ // Ignore spread elements
291
+ return
292
+ }
293
+
294
+ if (prop.key.type === 'Identifier' && ignoredKeys.includes(prop.key.name)) {
295
+ // Ignore specific keys defined in settings
296
+ return
297
+ }
298
+
299
+ parseNodeRecursive(
300
+ rootNode,
301
+ isUsedByClassNamesPlugin ? prop.key : prop.value,
302
+ cb,
303
+ skipConditional,
304
+ forceIsolation,
305
+ ignoredKeys
306
+ )
307
+ })
308
+ return
309
+ case 'Property':
310
+ parseNodeRecursive(rootNode, childNode.key, cb, skipConditional, forceIsolation, ignoredKeys)
311
+ return
312
+ case 'Literal':
313
+ trim = true
314
+ originalClassNamesValue = childNode.value
315
+ break
316
+ case 'TemplateElement':
317
+ originalClassNamesValue = childNode.value.raw
318
+ break
319
+ }
320
+ ({ classNames } = extractClassnamesFromValue(originalClassNamesValue))
321
+ classNames = removeDuplicatesFromArray(classNames)
322
+ if (classNames.length === 0) {
323
+ // Don't run for empty className
324
+ return
325
+ }
326
+ const targetNode = isolate ? null : rootNode
327
+ cb(classNames, targetNode)
328
+ }
329
+ }
330
+
331
+ function getTemplateElementPrefix(text, raw) {
332
+ const idx = text.indexOf(raw)
333
+ if (idx === 0) {
334
+ return ''
335
+ }
336
+ return text.split(raw).shift()
337
+ }
338
+
339
+ function getTemplateElementSuffix(text, raw) {
340
+ if (text.indexOf(raw) === -1) {
341
+ return ''
342
+ }
343
+ return text.split(raw).pop()
344
+ }
345
+
346
+ function getTemplateElementBody(text, prefix, suffix) {
347
+ let arr = text.split(prefix)
348
+ arr.shift()
349
+ let body = arr.join(prefix)
350
+ arr = body.split(suffix)
351
+ arr.pop()
352
+ return arr.join(suffix)
353
+ }
354
+
355
+ module.exports = {
356
+ calleeToString,
357
+ extractRangeFromNode,
358
+ extractValueFromNode,
359
+ extractClassnamesFromValue,
360
+ isClassAttribute,
361
+ isLiteralAttributeValue,
362
+ isArrayExpression,
363
+ isObjectExpression,
364
+ isValidJSXAttribute,
365
+ isValidVueAttribute,
366
+ isVLiteralValue,
367
+ parseNodeRecursive,
368
+ getTemplateElementPrefix,
369
+ getTemplateElementSuffix,
370
+ getTemplateElementBody,
371
+ }
@@ -0,0 +1,8 @@
1
+ 'use strict'
2
+
3
+ // Copied from https://github.com/yannickcr/eslint-plugin-react/blob/master/lib/util/docsUrl.js
4
+ function docsUrl(ruleName) {
5
+ return `https://github.com/master-co/css/tree/beta/packages/eslint-plugin/docs/rules/${ruleName}.md`
6
+ }
7
+
8
+ module.exports = docsUrl
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @see parserServices https://eslint.org/docs/developer-guide/working-with-rules#the-context-object
3
+ * @param {Object} context
4
+ * @param {Function} templateBodyVisitor
5
+ * @param {Function} scriptVisitor
6
+ * @returns
7
+ */
8
+ function defineTemplateBodyVisitor(context, templateBodyVisitor, scriptVisitor) {
9
+ if (context.parserServices == null || context.parserServices.defineTemplateBodyVisitor == null) {
10
+ // Default parser
11
+ return scriptVisitor
12
+ }
13
+
14
+ // Using "vue-eslint-parser" requires this setup
15
+ // @see https://eslint.org/docs/developer-guide/working-with-rules#the-context-object
16
+ return context.parserServices.defineTemplateBodyVisitor(templateBodyVisitor, scriptVisitor)
17
+ }
18
+
19
+ module.exports = {
20
+ defineTemplateBodyVisitor,
21
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Escapes a string to be used in a regular expression
3
+ * Copied from https://stackoverflow.com/a/3561711.
4
+ * @param {string} input
5
+ * @returns {string}
6
+ */
7
+ function escapeRegex(input) {
8
+ return input.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
9
+ }
10
+
11
+ const separatorRegEx = /([\t\n\f\r ]+)/
12
+
13
+ module.exports = {
14
+ escapeRegex,
15
+ separatorRegEx,
16
+ }
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ function removeDuplicatesFromArray(arr) {
4
+ return [...new Set(arr)]
5
+ }
6
+
7
+ module.exports = removeDuplicatesFromArray
@@ -0,0 +1,18 @@
1
+ 'use strict'
2
+
3
+ function removeDuplicatesFromClassnamesAndWhitespaces(orderedClassNames, whitespaces, headSpace, tailSpace) {
4
+ let previous = orderedClassNames[0]
5
+ const offset = (!headSpace && !tailSpace) || tailSpace ? -1 : 0
6
+ for (let i = 1; i < orderedClassNames.length; i++) {
7
+ const cls = orderedClassNames[i]
8
+ // This function assumes that the list of classNames is ordered, so just comparing to the previous className is enough
9
+ if (cls === previous) {
10
+ orderedClassNames.splice(i, 1)
11
+ whitespaces.splice(i + offset, 1)
12
+ i--
13
+ }
14
+ previous = cls
15
+ }
16
+ }
17
+
18
+ module.exports = removeDuplicatesFromClassnamesAndWhitespaces
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+ const exploreConfig = require('explore-config').default
3
+
4
+ function getOption(context, name) {
5
+ // Options (defined at rule level)
6
+ const options = context.options[0] || {}
7
+ if (options[name] != undefined) {
8
+ return options[name]
9
+ }
10
+ // Settings (defined at plugin level, shared accross rules)
11
+ if (context.settings && context.settings.mastercss && context.settings.mastercss[name] != undefined) {
12
+ return context.settings.mastercss[name]
13
+ }
14
+ // Fallback to defaults
15
+ switch (name) {
16
+ case 'callees':
17
+ return ['classnames', 'clsx', 'ctl', 'cva']
18
+ case 'ignoredKeys':
19
+ return ['compoundVariants', 'defaultVariants']
20
+ case 'classRegex':
21
+ return '^class(Name)?$'
22
+ case 'config':
23
+ return exploreConfig('master.css.*', {})
24
+ case 'cssFiles':
25
+ return ['**/*.css', '!**/node_modules', '!**/.*', '!**/dist', '!**/build']
26
+ case 'cssFilesRefreshRate':
27
+ return 5_000
28
+ case 'removeDuplicates':
29
+ return true
30
+ case 'skipClassAttribute':
31
+ return false
32
+ case 'tags':
33
+ return []
34
+ case 'whitelist':
35
+ return []
36
+ }
37
+ }
38
+
39
+ module.exports = getOption
package/package.json ADDED
@@ -0,0 +1 @@
1
+ {"name":"@master/eslint-plugin-css","scripts":{"test":"mocha tests --recursive --timeout 10000"},"keywords":["mastercss","eslint","eslintplugin"],"license":"MIT","description":"Master CSS ESLint Plugin","author":"Aoyue Design LLC.","funding":"https://css.master.co/docs/donate","homepage":"https://css.master.co","bugs":{"url":"https://github.com/master-co/css/issues"},"repository":{"type":"git","url":"https://github.com/master-co/css.git","directory":"packages/eslint-plugin"},"sideEffects":false,"main":"./lib/index.js","exports":"./lib/index.js","publishConfig":{"access":"public"},"dependencies":{"@master/css":"^2.0.0-beta.175","requireindex":"^1.2.0","explore-config":"^2.2.10"},"devDependencies":{"@angular-eslint/template-parser":"^16.2.0","@typescript-eslint/parser":"^5.50.0","@types/node":"^20.6.0","mocha":"^10.2.0","eslint":"^7.1.0","vue-eslint-parser":"^9.3.1"},"version":"2.0.0-beta.175"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @fileoverview Use a consistent orders for the Master CSS classnames, based on property then on variants
3
+ * @author Miles
4
+ */
5
+ 'use strict'
6
+
7
+ // Modified from https://github.com/francoismassart/eslint-plugin-tailwindcss
8
+
9
+ //------------------------------------------------------------------------------
10
+ // Requirements
11
+ //------------------------------------------------------------------------------
12
+
13
+ var rule = require('../lib/rules/class-order')
14
+ var RuleTester = require('eslint').RuleTester
15
+ var parserOptions = {
16
+ ecmaVersion: 2019,
17
+ sourceType: 'module',
18
+ ecmaFeatures: {
19
+ jsx: true,
20
+ },
21
+ }
22
+
23
+ //------------------------------------------------------------------------------
24
+ // Tests
25
+ //------------------------------------------------------------------------------
26
+
27
+ describe('class-order', function () {
28
+ it('basic', function () {
29
+ var ruleTester = new RuleTester({ parserOptions })
30
+ const generateErrors = (count) => {
31
+ const errors = []
32
+
33
+ for (let i = 0; i < count; i++) {
34
+ errors.push({
35
+ messageId: 'invalidOrder',
36
+ })
37
+ }
38
+
39
+ return errors
40
+ }
41
+
42
+ const errors = generateErrors(1)
43
+
44
+ ruleTester.run('class-order', rule, {
45
+ valid: [
46
+ {
47
+ code: `<div class="m:8 p:8 bg:black fg:white f:24">Simple, basic</div>`,
48
+ },
49
+ {
50
+ code: `<div test="m:8 p:8 bg:black fg:white f:24">Simple, using 'test' prop</div>`,
51
+ options: [
52
+ {
53
+ classRegex: '^test$',
54
+ },
55
+ ],
56
+ },
57
+ ],
58
+ invalid: [
59
+ {
60
+ code: `
61
+ export interface FakePropsInterface {
62
+ readonly name?: string;
63
+ }
64
+ function Fake({
65
+ name = 'yolo'
66
+ }: FakeProps) {
67
+ return (
68
+ <>
69
+ <h1 className={"m:8 bg:black p:8 fg:white f:24"}>Welcome {name}</h1>
70
+ <p>Bye {name}</p>
71
+ </>
72
+ );
73
+ }
74
+ export default Fake;
75
+ `,
76
+ output: `
77
+ export interface FakePropsInterface {
78
+ readonly name?: string;
79
+ }
80
+ function Fake({
81
+ name = 'yolo'
82
+ }: FakeProps) {
83
+ return (
84
+ <>
85
+ <h1 className={"m:8 p:8 bg:black fg:white f:24"}>Welcome {name}</h1>
86
+ <p>Bye {name}</p>
87
+ </>
88
+ );
89
+ }
90
+ export default Fake;
91
+ `,
92
+ parser: require.resolve('@typescript-eslint/parser'),
93
+ errors: errors,
94
+ },
95
+ {
96
+ code: `<div class="fg:white f:24 m:8 p:8 bg:black">Classnames will be ordered</div>`,
97
+ output: `<div class="m:8 p:8 bg:black fg:white f:24">Classnames will be ordered</div>`,
98
+ errors: errors,
99
+ },
100
+ {
101
+ code: `<template><div class="m:8 bg:black p:8 fg:white f:24">Enhancing readability</div></template>`,
102
+ output: `<template><div class="m:8 p:8 bg:black fg:white f:24">Enhancing readability</div></template>`,
103
+ errors: errors,
104
+ filename: 'test.vue',
105
+ parser: require.resolve('vue-eslint-parser'),
106
+ },
107
+ {
108
+ code: `
109
+ <div class="
110
+ m:8
111
+ bg:black
112
+ p:8
113
+ fg:white
114
+ f:24
115
+ ">
116
+ :)
117
+ </div>`,
118
+ output: `
119
+ <div class="
120
+ m:8
121
+ p:8
122
+ bg:black
123
+ fg:white
124
+ f:24
125
+ ">
126
+ :)
127
+ </div>`,
128
+ errors: errors,
129
+ parser: require.resolve('@angular-eslint/template-parser'),
130
+ }
131
+ ],
132
+ })
133
+ })
134
+ })