UNPKG

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