UNPKG

11.5 kBJavaScriptView Raw
1const _import = require('esm')(module)
2const {gray} = require('kleur')
3const path = require('path')
4const fs = require('fs')
5const stream = require('stream')
6const promisify = require('util').promisify
7const postcss = require('postcss')
8const selectorTokenizer = require('css-selector-tokenizer')
9const finished = promisify(stream.finished)
10const mkdir = promisify(fs.mkdir)
11const createWriteStream = fs.createWriteStream
12
13const unsupportedShorthands = {
14 'border-bottom': ['border-bottom-width', 'border-bottom-style', 'border-bottom-color'],
15 'border-color': ['border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color'],
16 'border-left': ['border-left-width', 'border-left-style', 'border-left-color'],
17 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'],
18 'border-right': ['border-right-width', 'border-right-style', 'border-right-color'],
19 'border-style': ['border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style'],
20 'border-top': ['border-top-width', 'border-top-style', 'border-top-color'],
21 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'],
22 'column-rule': ['column-rule-width', 'column-rule-style', 'column-rule-color'],
23 'flex-flow': ['flex-direction', 'flex-wrap'],
24 'grid-area': ['grid-column-start', 'grid-column-end', 'grid-row-start', 'grid-row-end'],
25 'grid-column': ['grid-column-start', 'grid-column-end'],
26 'grid-row': ['grid-row-start', 'grid-row-end'],
27 'grid-template': ['grid-template-rows', 'grid-template-columns', 'grid-template-areas'],
28 'list-style': ['list-style-type', 'list-style-image', 'list-style-position'],
29 'place-content': ['align-content', 'justify-content'],
30 'place-items': ['align-items', 'justify-items'],
31 'place-self': ['align-self', 'justify-self'],
32 'text-decoration': ['text-decoration-line', 'text-decoration-color', 'text-decoration-style', 'text-decoration-thickness'],
33 animation: ['animation-name', 'animation-duration', 'animation-timing-function', 'animation-delay', 'animation-iteration-count', 'animation-direction', 'animation-fill-mode', 'animation-play-state'],
34 background: ['background-clip', 'background-color', 'background-image', 'background-origin', 'background-position', 'background-repeat', 'background-size', 'background-attachment'],
35 border: ['border-bottom-width', 'border-bottom-style', 'border-bottom-color', 'border-left-width', 'border-left-style', 'border-left-color', 'border-right-width', 'border-right-style', 'border-right-color', 'border-top-width', 'border-top-style', 'border-top-color', 'border-color', 'border-style', 'border-width'],
36 columns: ['column-width', 'column-count'],
37 flex: ['flex-grow', 'flex-shrink', 'flex-basis'],
38 font: ['font-style', 'font-variant', 'font-weight', 'font-stretch', 'font-size', 'line-height', 'font-family'],
39 grid: ['grid-template-rows', 'grid-template-columns', 'grid-template-areas', 'grid-auto-rows', 'grid-auto-columns', 'grid-auto-flow'],
40 margin: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
41 offset: ['offset-position', 'offset-path', 'offset-distance', 'offset-rotate', 'offset-anchor'],
42 outline: ['outline-style', 'outline-width', 'outline-color'],
43 overflow: ['overflow-x', 'overflow-y'],
44 padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
45 transition: ['transition-property', 'transition-duration', 'transition-timing-function', 'transition-delay']
46}
47
48const supportedShorthands = {
49 'border-color': require('./lib/shorthands/border-color.js'),
50 'border-radius': require('./lib/shorthands/border-radius.js'),
51 'border-style': require('./lib/shorthands/border-style.js'),
52 'border-width': require('./lib/shorthands/border-width.js'),
53 margin: require('./lib/shorthands/margin.js'),
54 overflow: require('./lib/shorthands/overflow.js'),
55 padding: require('./lib/shorthands/padding.js')
56}
57
58const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
59
60const letterCount = letters.length
61
62const isEqualArray = (a, b) => {
63 if (a.length !== b.length) return false
64
65 for (let i = 0; i < a.length; i++) {
66 if (a[i] !== b[i]) return false
67 }
68
69 return true
70}
71
72const processNodes = (nodes, selector, template, templateKeys, position = -1) => {
73 let results = {}
74
75 for (const node of nodes) {
76 if (node.type === 'decl') {
77 const prop = node.prop
78 const value = node.value
79
80 results[`${template} ${selector} ${prop}`] = {
81 template,
82 selector,
83 prop,
84 value
85 }
86 } else if (node.type === 'atrule') {
87 const t = template.replace('{}', `{ @${node.name} ${node.params} {} }`)
88 const i = templateKeys.indexOf(t)
89 let p = position + 1
90
91 if (i === -1) {
92 templateKeys.splice(p, 0, t)
93 } else {
94 p = i
95 }
96
97 results = {
98 ...results,
99 ...processNodes(node.nodes, selector, t, templateKeys, p)
100 }
101 } else if (node.type === 'rule') {
102 if (selector) throw Error('nested rule found')
103
104 const parsed = selectorTokenizer.parse(node.selector)
105
106 for (const n of parsed.nodes) {
107 if (n.nodes.filter((n) => n.type === 'spacing' || n.type.includes('pseudo')).length !== n.nodes.length) {
108 throw Error('non-pseudo selector found')
109 }
110
111 results = {
112 ...results,
113 ...processNodes(node.nodes, selectorTokenizer.stringify(n).trim(), template, templateKeys, position)
114 }
115 }
116 }
117 }
118
119 return results
120}
121
122const processSelectors = (node) => {
123 const results = []
124
125 if (node.nodes) {
126 for (const n of node.nodes) {
127 if (n.type === 'class') {
128 results.push(n.name)
129 }
130
131 if (n.nodes) {
132 results.push(...processSelectors(n))
133 }
134 }
135 }
136
137 return results
138}
139
140const run = async (args) => {
141 let id = 0
142 const existingIds = []
143
144 const uniqueId = () => {
145 let result = ''
146
147 do {
148 let i = id++
149 result = ''
150
151 let r
152
153 do {
154 r = i % letterCount
155 i = (i - r) / letterCount
156
157 result += letters[r]
158 } while (i)
159 } while (existingIds.includes(result))
160
161 return result
162 }
163
164 const input = _import(`${args.input}?${Date.now()}`)
165
166 await mkdir(path.dirname(path.join(process.cwd(), args.output)), {recursive: true})
167
168 const output = {
169 css: createWriteStream(path.join(process.cwd(), `${args.output}.css`)),
170 js: createWriteStream(path.join(process.cwd(), `${args.output}.mjs`))
171 }
172
173 const map = {}
174 const tree = {}
175 const ids = {}
176
177 if (input._start) {
178 output.css.write(input._start)
179
180 postcss.parse(input._start).walkRules((rule) => {
181 const parsed = selectorTokenizer.parse(rule.selector)
182
183 existingIds.push(...processSelectors(parsed))
184 })
185 }
186
187 if (input._end) {
188 postcss.parse(input._end).walkRules((rule) => {
189 const parsed = selectorTokenizer.parse(rule.selector)
190
191 existingIds.push(...processSelectors(parsed))
192 })
193 }
194
195 const templateKeys = []
196
197 for (const [name, raw] of Object.entries(input.styles)) {
198 const parsed = postcss.parse(raw)
199 const processed = Object.values(processNodes(parsed.nodes, '', '{}', templateKeys))
200 const bannedLonghands = {}
201
202 for (const {template, selector, prop} of processed) {
203 if (unsupportedShorthands[prop] != null) {
204 if (bannedLonghands[`${template} ${selector}`] == null) {
205 bannedLonghands[`${template} ${selector}`] = []
206 }
207
208 bannedLonghands[`${template} ${selector}`].push(...unsupportedShorthands[prop])
209 }
210 }
211
212 for (const {template, selector, prop, value} of processed) {
213 if (bannedLonghands[`${template} ${selector}`] != null) {
214 if (bannedLonghands[`${template} ${selector}`].includes(prop)) {
215 console.warn(`${prop} found with shorthand`)
216 }
217 }
218
219 tree[template] = tree[template] || []
220
221 const index = tree[template].findIndex((r) => r.selector === selector && r.prop === prop && r.value === value)
222
223 if (index === -1) {
224 tree[template].push({
225 names: [name],
226 selector,
227 prop,
228 value
229 })
230 } else {
231 tree[template][index].names.push(name)
232 }
233 }
234 }
235
236 templateKeys.push('{}')
237
238 for (const template of templateKeys) {
239 const branch = tree[template]
240 const remainders = {}
241 const rules = []
242
243 while (branch.length) {
244 const {selector, prop, value, names} = branch.shift()
245
246 if (names.length > 1) {
247 let cls
248
249 if (ids[names.join()]) {
250 cls = ids[names.join()]
251 } else {
252 cls = uniqueId()
253
254 ids[names.join()] = cls
255
256 for (const name of names) {
257 map[name] = map[name] || []
258
259 map[name].push(cls)
260 }
261 }
262
263 const decls = {
264 [prop]: value
265 }
266
267 let i = 0
268
269 while (i < branch.length) {
270 if (isEqualArray(branch[i].names, names) && branch[i].selector === selector) {
271 decls[branch[i].prop] = branch[i].value
272
273 branch.splice(i, 1)
274 } else {
275 i++
276 }
277 }
278
279 for (const shorthand of Object.values(supportedShorthands)) {
280 shorthand.collapse(decls)
281 }
282
283 rules.push(`.${cls}${selector} { ${Object.keys(decls).map((prop) => `${prop}: ${decls[prop]}`).join('; ')}; }`)
284 } else {
285 const name = names[0]
286
287 if (remainders[`${selector} ${name}`] == null) {
288 remainders[`${selector} ${name}`] = {
289 selector,
290 name,
291 decls: {}
292 }
293 }
294
295 remainders[`${selector} ${name}`].decls[prop] = value
296 }
297 }
298
299 for (const {selector, name, decls} of Object.values(remainders)) {
300 const cls = ids[name] || uniqueId()
301
302 ids[name] = cls
303
304 for (const shorthand of Object.values(supportedShorthands)) {
305 shorthand.collapse(decls)
306 }
307
308 rules.push(`.${cls}${selector} { ${Object.keys(decls).map((prop) => `${prop}: ${decls[prop]}`).join('; ')}; }`)
309
310 map[name] = map[name] || []
311
312 if (!map[name].includes(cls)) {
313 map[name].push(cls)
314 }
315 }
316
317 const line = template.replace('{}', `{ ${rules.join('')} }`)
318
319 output.css.write(line.substring(2, line.length - 2))
320 }
321
322 output.css.end(input._end != null ? input._end : '')
323
324 for (const name of Object.keys(map)) {
325 map[name] = map[name].join(' ')
326 }
327
328 const stringifiedMap = JSON.stringify(map, null, 2)
329
330 if (args.dev) {
331 output.js.end(`export const classes = new Proxy(${stringifiedMap}, {
332 get(target, prop) {
333 if (target.hasOwnProperty(prop)) {
334 return target[prop]
335 }
336
337 throw Error(\`\${prop} is undefined\`)
338 }
339 })`)
340 } else {
341 output.js.end(`export const classes = ${stringifiedMap}`)
342 }
343
344 return Promise.all([
345 finished(output.css).then(() => {
346 console.log(`${gray('[css]')} saved ${args.output}.css`)
347 }),
348 finished(output.js).then(() => {
349 console.log(`${gray('[css]')} saved ${args.output}.mjs`)
350 })
351 ])
352}
353
354module.exports = (args) => {
355 args.input = path.join(process.cwd(), args.input)
356
357 if (!args.watch) {
358 return run(args)
359 }
360
361 run(args)
362
363 let changed = false
364
365 fs.watch(args.input, () => {
366 if (!changed) {
367 changed = true
368
369 setTimeout(() => {
370 run(args)
371
372 changed = false
373 }, 100)
374 }
375 })
376}