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 | 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 |
|
112 | const 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 |
|
130 | const 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 |
|
340 | module.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 | }
|