1 | const _import = require('esm')(module)
|
2 | const {gray} = require('kleur')
|
3 | const path = require('path')
|
4 | const fs = require('fs')
|
5 | const stream = require('stream')
|
6 | const promisify = require('util').promisify
|
7 | const postcss = require('postcss')
|
8 | const selectorTokenizer = require('css-selector-tokenizer')
|
9 | const finished = promisify(stream.finished)
|
10 | const mkdir = promisify(fs.mkdir)
|
11 | const createWriteStream = fs.createWriteStream
|
12 |
|
13 | const 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 |
|
48 | const 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 |
|
58 | const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
59 |
|
60 | const letterCount = letters.length
|
61 |
|
62 | const 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 |
|
72 | const 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 |
|
114 | const 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 |
|
132 | const 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 |
|
402 | module.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 | }
|