/
index.js
291 lines (241 loc) · 10 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import _ from "lodash"
import { vendor } from "postcss"
import {
isCustomProperty,
isStandardProperty,
report,
ruleMessages,
validateOptions,
} from "../../utils"
export const ruleName = "declaration-block-properties-order"
export const messages = ruleMessages(ruleName, {
expected: (first, second) => `Expected property "${first}" to come before property "${second}"`,
expectedEmptyLineBetween: (first, second) => `Expected an empty line between property "${first}" and property "${second}"`,
rejectedEmptyLineBetween: (first, second) => `Unexpected empty line between property "${first} and property "${second}"`,
})
export default function (expectation, options) {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: expectation,
possible: validatePrimaryOption,
}, {
actual: options,
possible: {
unspecified: [ "top", "bottom", "ignore", "bottomAlphabetical" ],
},
optional: true,
})
if (!validOptions) { return }
const alphabetical = expectation === "alphabetical"
const expectedOrder = (alphabetical) ? null : createExpectedOrder(expectation)
// By default, ignore unspecified properties
const unspecified = _.get(options, ["unspecified"], "ignore")
// Shallow loop
root.each(node => {
if (node.type === "rule" || node.type === "atrule") {
checkNode(node)
}
})
function checkNode(node) {
const allPropData = []
let lastKnownSeparatedGroup = 1
node.each(child => {
// If the child has nested nodes with child
// (e.g. a rule nested within a rule), make
// sure to check the children
if (child.nodes && child.nodes.length) {
checkNode(child)
}
if (child.type !== "decl") { return }
const { prop } = child
if (!isStandardProperty(prop)) { return }
if (isCustomProperty(prop)) { return }
let unprefixedPropName = vendor.unprefixed(prop)
// Hack to allow -moz-osx-font-smoothing to be understood
// just like -webkit-font-smoothing
if (unprefixedPropName.indexOf("osx-") === 0) {
unprefixedPropName = unprefixedPropName.slice(4)
}
const propData = {
name: prop,
unprefixedName: unprefixedPropName,
orderData: (alphabetical) ? null : getOrderData(expectedOrder, unprefixedPropName),
before: child.raw("before"),
index: allPropData.length,
node: child,
}
const previousPropData = _.last(allPropData)
allPropData.push(propData)
// Skip first decl
if (!previousPropData) { return }
const isCorrectOrder = (alphabetical)
? checkAlpabeticalOrder(previousPropData, propData)
: checkOrder(previousPropData, propData)
if (isCorrectOrder) { return }
complain({
message: messages.expected(propData.name, previousPropData.name),
node: child,
})
})
function checkOrder(firstPropData, secondPropData) {
// If the unprefixed property names are the same, resort to alphabetical ordering
if (firstPropData.unprefixedName === secondPropData.unprefixedName) {
return firstPropData.name <= secondPropData.name
}
const firstPropIsUnspecified = !firstPropData.orderData
const secondPropIsUnspecified = !secondPropData.orderData
// Now check newlines between ...
const firstPropSeparatedGroup = (!firstPropIsUnspecified)
? firstPropData.orderData.separatedGroup
: lastKnownSeparatedGroup
const secondPropSeparatedGroup = (!secondPropIsUnspecified)
? secondPropData.orderData.separatedGroup
: lastKnownSeparatedGroup
if (firstPropSeparatedGroup !== secondPropSeparatedGroup && !secondPropIsUnspecified) {
// Get an array of just the property groups, remove any solo properties
const groups = _.reject(expectation, _.isString)
// secondProp seperatedGroups start at 2 so we minus 2 to get the 1st item
// from our groups array
const emptyLineBefore = _.get(groups[secondPropSeparatedGroup - 2], "emptyLineBefore")
if (!hasEmptyLineBefore(secondPropData.node) && emptyLineBefore === "always") {
complain({
message: messages.expectedEmptyLineBetween(secondPropData.name, firstPropData.name),
node: secondPropData.node,
})
} else if (hasEmptyLineBefore(secondPropData.node) && emptyLineBefore === "never") {
complain({
message: messages.rejectedEmptyLineBetween(secondPropData.name, firstPropData.name),
node: secondPropData.node,
})
}
}
lastKnownSeparatedGroup = secondPropSeparatedGroup
// Now check actual known properties ...
if (!firstPropIsUnspecified && !secondPropIsUnspecified) {
return firstPropData.orderData.expectedPosition <= secondPropData.orderData.expectedPosition
}
if (firstPropIsUnspecified && !secondPropIsUnspecified) {
// If first prop is unspecified, look for a specified prop before it to
// compare to the current prop
const priorSpecifiedPropData = _.findLast(allPropData.slice(0, -1), d => !!d.orderData)
if (
priorSpecifiedPropData && priorSpecifiedPropData.orderData
&& priorSpecifiedPropData.orderData.expectedPosition > secondPropData.orderData.expectedPosition
) {
complain({
message: messages.expected(secondPropData.name, priorSpecifiedPropData.name),
node: secondPropData.node,
})
return true // avoid logging another warning
}
}
// Now deal with unspecified props ...
// Starting with bottomAlphabetical as it requires more specific conditionals
if (unspecified === "bottomAlphabetical" && !firstPropIsUnspecified &&
secondPropIsUnspecified) { return true }
if (unspecified === "bottomAlphabetical" &&
secondPropIsUnspecified &&
firstPropIsUnspecified) {
if (checkAlpabeticalOrder(firstPropData, secondPropData)) { return true }
else { return false }
}
if (unspecified === "bottomAlphabetical" && firstPropIsUnspecified) { return false }
if (firstPropIsUnspecified && secondPropIsUnspecified) { return true }
if (unspecified === "ignore" && (firstPropIsUnspecified || secondPropIsUnspecified)) { return true }
if (unspecified === "top" && firstPropIsUnspecified) { return true }
if (unspecified === "top" && secondPropIsUnspecified) { return false }
if (unspecified === "bottom" && secondPropIsUnspecified) { return true }
if (unspecified === "bottom" && firstPropIsUnspecified) { return false }
}
}
function complain({ message, node }) {
report({
message,
node,
result,
ruleName,
})
}
}
}
function createExpectedOrder(input) {
const order = {}
let separatedGroup = 1
let expectedPosition = 0
appendGroup(input, 1)
function appendGroup(items) {
items.forEach(item => appendItem(item, false))
}
function appendItem(item, inFlexibleGroup) {
if (_.isString(item)) {
// In flexible groups, the expectedPosition does not ascend
// to make that flexibility work;
// otherwise, it will always ascend
if (!inFlexibleGroup) { expectedPosition += 1 }
order[item] = { separatedGroup, expectedPosition }
return
}
// If item is not a string, it's a group ...
if (item.emptyLineBefore) {
separatedGroup += 1
}
if (!item.order || item.order === "strict") {
appendGroup(item.properties)
return
} else if (item.order === "flexible") {
expectedPosition += 1
item.properties.forEach(property => {
appendItem(property, true)
})
}
}
return order
}
function getOrderData(expectedOrder, propName) {
let orderData = expectedOrder[propName]
// If prop was not specified but has a hyphen
// (e.g. `padding-top`), try looking for the segment preceding the hyphen
// and use that index
if (!orderData && propName.lastIndexOf("-") !== -1) {
const propNamePreHyphen = propName.slice(0, propName.lastIndexOf("-"))
orderData = getOrderData(expectedOrder, propNamePreHyphen)
}
return orderData
}
function hasEmptyLineBefore(decl) {
if (/\r?\n\s*\r?\n/.test(decl.raw("before"))) { return true }
const prevNode = decl.prev()
if (!prevNode) { return false }
if (prevNode.type !== "comment") { return false }
if (/\r?\n\s*\r?\n/.test(prevNode.raw("before"))) { return true }
return false
}
function checkAlpabeticalOrder(firstPropData, secondPropData) {
// If unprefixed prop names are the same, compare the prefixed versions
if (firstPropData.unprefixedName === secondPropData.unprefixedName) {
return firstPropData.name <= secondPropData.name
}
return firstPropData.unprefixedName < secondPropData.unprefixedName
}
function validatePrimaryOption(actualOptions) {
if (actualOptions === "alphabetical") { return true }
if (!Array.isArray(actualOptions)) { return false }
// Every item in the array must be a string or an object
// with a "properties" property
if (actualOptions.every(item => {
if (_.isString(item)) { return true }
return _.isPlainObject(item) && !_.isUndefined(item.properties)
})) { return true }
const objectItems = actualOptions.filter(_.isPlainObject)
// Every object-item's "emptyLineBefore" must be "always" or "never"
if (objectItems.every(item => {
if (_.isUndefined(item.emptyLineBefore)) { return true }
return _.includes([ "always", "never" ], item.emptyLineBefore)
})) { return true }
// Every object-item's "type" property must be "strict" or "flexible"
if (objectItems.every(item => {
if (_.isUndefined(item.type)) { return true }
return _.includes([ "string", "flexible" ], item.type)
})) { return true }
return false
}