UNPKG

12.8 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) => {
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}@${node.name} ${node.params}`
88
89 results = {
90 ...results,
91 ...processNodes(node.nodes, selector, t)
92 }
93 } else if (node.type === 'rule') {
94 if (selector) throw Error('nested rule found')
95
96 const parsed = selectorTokenizer.parse(node.selector)
97
98 for (const n of parsed.nodes) {
99 if (n.nodes.filter((n) => n.type === 'spacing' || n.type.includes('pseudo')).length !== n.nodes.length) {
100 throw Error('non-pseudo selector found')
101 }
102
103 results = {
104 ...results,
105 ...processNodes(node.nodes, selectorTokenizer.stringify(n).trim(), template)
106 }
107 }
108 }
109 }
110
111 return results
112}
113
114const processSelectors = (node) => {
115 const results = []
116
117 if (node.nodes) {
118 for (const n of node.nodes) {
119 if (n.type === 'class') {
120 results.push(n.name)
121 }
122
123 if (n.nodes) {
124 results.push(...processSelectors(n))
125 }
126 }
127 }
128
129 return results
130}
131
132const run = async (args) => {
133 let id = 0
134 const existingIds = []
135
136 const uniqueId = () => {
137 let result = ''
138
139 do {
140 let i = id++
141 result = ''
142
143 let r
144
145 do {
146 r = i % letterCount
147 i = (i - r) / letterCount
148
149 result += letters[r]
150 } while (i)
151 } while (existingIds.includes(result))
152
153 return result
154 }
155
156 const input = _import(`${args.input}?${Date.now()}`)
157
158 await mkdir(path.dirname(path.join(process.cwd(), args.output)), {recursive: true})
159
160 const output = {
161 css: createWriteStream(path.join(process.cwd(), `${args.output}.css`)),
162 js: createWriteStream(path.join(process.cwd(), `${args.output}.mjs`))
163 }
164
165 const map = {}
166 const tree = {}
167 const ids = {}
168
169 if (input._start) {
170 output.css.write(input._start)
171
172 postcss.parse(input._start).walkRules((rule) => {
173 const parsed = selectorTokenizer.parse(rule.selector)
174
175 existingIds.push(...processSelectors(parsed))
176 })
177 }
178
179 if (input._end) {
180 postcss.parse(input._end).walkRules((rule) => {
181 const parsed = selectorTokenizer.parse(rule.selector)
182
183 existingIds.push(...processSelectors(parsed))
184 })
185 }
186
187 const unorderedTemplates = []
188 const orderedTemplates = []
189
190 for (const [name, raw] of Object.entries(input.styles)) {
191 const parsed = postcss.parse(typeof raw === 'function' ? raw(input.styles) : raw)
192 const processed = Object.values(processNodes(parsed.nodes, '', ''))
193 const bannedLonghands = {}
194 const templates = []
195
196 for (const {template, selector, prop} of processed) {
197 if (!templates.includes(template)) templates.push(template)
198
199 if (unsupportedShorthands[prop] != null) {
200 if (bannedLonghands[`${template} ${selector}`] == null) {
201 bannedLonghands[`${template} ${selector}`] = []
202 }
203
204 bannedLonghands[`${template} ${selector}`].push(...unsupportedShorthands[prop])
205 }
206 }
207
208 for (const {template, selector, prop, value} of processed) {
209 if (bannedLonghands[`${template} ${selector}`] != null) {
210 if (bannedLonghands[`${template} ${selector}`].includes(prop)) {
211 console.warn(`${prop} found with shorthand`)
212 }
213 }
214
215 tree[template] = tree[template] || []
216
217 const index = tree[template].findIndex((r) => r.selector === selector && r.prop === prop && r.value === value)
218
219 if (index === -1) {
220 tree[template].push({
221 names: [name],
222 selector,
223 prop,
224 value
225 })
226 } else {
227 tree[template][index].names.push(name)
228 }
229 }
230
231 let index = 0
232
233 if (templates.length > 1) {
234 for (const template of templates) {
235 const unorderedIndex = unorderedTemplates.indexOf(template)
236
237 if (unorderedIndex > -1) {
238 unorderedTemplates.splice(unorderedIndex, 1)
239 }
240
241 const orderedIndex = orderedTemplates.indexOf(template)
242
243 if (orderedIndex > -1) {
244 index = orderedIndex
245 } else {
246 orderedTemplates.splice(++index, 0, template)
247 }
248 }
249 } else if (!unorderedTemplates.includes(templates[0]) && !orderedTemplates.includes(templates[0])) {
250 unorderedTemplates.push(templates[0])
251 }
252 }
253
254 const concatedTemplates = unorderedTemplates.concat(orderedTemplates)
255
256 const templateToArray = (template) => template.split('@')
257
258 for (let i = 0; i < concatedTemplates.length; i++) {
259 const template = concatedTemplates[i]
260 const splitAtrules = templateToArray(template)
261 const prevAtrules = templateToArray(concatedTemplates[i - 1] || '')
262 const nextAtrules = templateToArray(concatedTemplates[i + 1] || '')
263
264 let startLine = ''
265 let endLine = ''
266
267 for (let i = 0; i < splitAtrules.length; i++) {
268 if (splitAtrules[i] !== prevAtrules[i]) {
269 const remainder = splitAtrules.slice(i)
270
271 startLine = remainder.map((part) => (part ? ` @${part} { ` : '')).join('')
272
273 break
274 }
275 }
276
277 for (let i = splitAtrules.length - 1; i >= 0; i--) {
278 if (splitAtrules[i] !== nextAtrules[i]) {
279 endLine += ' } '
280 } else {
281 break
282 }
283 }
284
285 const branch = tree[template]
286 const remainders = {}
287 const rules = []
288
289 while (branch.length) {
290 const {selector, prop, value, names} = branch.shift()
291
292 if (names.length > 1) {
293 let cls
294
295 if (ids[names.join()]) {
296 cls = ids[names.join()]
297 } else {
298 cls = uniqueId()
299
300 ids[names.join()] = cls
301
302 for (const name of names) {
303 map[name] = map[name] || []
304
305 map[name].push(cls)
306 }
307 }
308
309 const decls = {
310 [prop]: value
311 }
312
313 let i = 0
314
315 while (i < branch.length) {
316 if (isEqualArray(branch[i].names, names) && branch[i].selector === selector) {
317 decls[branch[i].prop] = branch[i].value
318
319 branch.splice(i, 1)
320 } else {
321 i++
322 }
323 }
324
325 for (const shorthand of Object.values(supportedShorthands)) {
326 shorthand.collapse(decls)
327 }
328
329 rules.push(`.${cls}${selector} { ${Object.keys(decls).map((prop) => `${prop}: ${decls[prop]}`).join('; ')}; }`)
330 } else {
331 const name = names[0]
332
333 if (remainders[`${selector} ${name}`] == null) {
334 remainders[`${selector} ${name}`] = {
335 selector,
336 name,
337 decls: {}
338 }
339 }
340
341 remainders[`${selector} ${name}`].decls[prop] = value
342 }
343 }
344
345 for (const {selector, name, decls} of Object.values(remainders)) {
346 const cls = ids[name] || uniqueId()
347
348 ids[name] = cls
349
350 for (const shorthand of Object.values(supportedShorthands)) {
351 shorthand.collapse(decls)
352 }
353
354 rules.push(`.${cls}${selector} { ${Object.keys(decls).map((prop) => `${prop}: ${decls[prop]}`).join('; ')}; }`)
355
356 map[name] = map[name] || []
357
358 if (!map[name].includes(cls)) {
359 map[name].push(cls)
360 }
361 }
362
363 output.css.write(startLine)
364
365 output.css.write(rules.join(''))
366
367 output.css.write(endLine)
368 }
369
370 output.css.end(input._end != null ? input._end : '')
371
372 for (const name of Object.keys(map)) {
373 map[name] = map[name].join(' ')
374 }
375
376 const stringifiedMap = JSON.stringify(map, null, 2)
377
378 if (args.dev) {
379 output.js.end(`export const classes = new Proxy(${stringifiedMap}, {
380 get(target, prop) {
381 if (target.hasOwnProperty(prop)) {
382 return target[prop]
383 }
384
385 throw Error(\`\${prop} is undefined\`)
386 }
387 })`)
388 } else {
389 output.js.end(`export const classes = ${stringifiedMap}`)
390 }
391
392 return Promise.all([
393 finished(output.css).then(() => {
394 console.log(`${gray('[css]')} saved ${args.output}.css`)
395 }),
396 finished(output.js).then(() => {
397 console.log(`${gray('[css]')} saved ${args.output}.mjs`)
398 })
399 ])
400}
401
402module.exports = (args) => {
403 args.input = path.join(process.cwd(), args.input)
404
405 if (!args.watch) {
406 return run(args)
407 }
408
409 run(args)
410
411 let changed = false
412
413 fs.watch(args.input, () => {
414 if (!changed) {
415 changed = true
416
417 setTimeout(() => {
418 run(args)
419
420 changed = false
421 }, 100)
422 }
423 })
424}