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, 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 |
|
122 | const 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 |
|
140 | const 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 |
|
354 | module.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 | }
|