UNPKG

10.9 kBJavaScriptView Raw
1import {gray} from 'kleur/colors'
2import path from 'path'
3import fs from 'fs'
4import stream from 'stream'
5import {promisify} from 'util'
6import postcss from 'postcss'
7import csso from 'csso'
8import selectorTokenizer from 'css-selector-tokenizer'
9import chokidar from 'chokidar'
10import sqlite3 from 'sqlite3'
11import shorthandLonghands from './lib/shorthand-longhands.js'
12import getClassNames from './lib/get-selectors.js'
13import createGetUniqueID from './lib/create-get-unique-id.js'
14
15const finished = promisify(stream.finished)
16const mkdir = promisify(fs.mkdir)
17const createWriteStream = fs.createWriteStream
18
19const buildData = async (db, node, context = {}) => {
20 if (node.type === 'decl') {
21 const prop = node.prop
22 const value = node.value
23
24 await db.run(
25 'INSERT INTO decl (atruleID, name, pseudo, prop, value) VALUES (?, ?, ?, ?, ?) ON CONFLICT (atruleID, name, pseudo, prop) DO UPDATE set value = ?',
26 context.parentAtruleID ?? 0,
27 context.name,
28 context.pseudo ?? '',
29 prop,
30 value,
31 value
32 )
33 } else if (node.type === 'atrule') {
34 const atruleName = `@${node.name} ${node.params}`
35
36 await db.run(
37 'INSERT INTO atrule (parentAtruleID, name) VALUES (?, ?) ON CONFLICT (parentAtruleID, name) DO NOTHING',
38 context.parentAtruleID ?? 0,
39 atruleName
40 )
41
42 const {atruleID} = await db.get(
43 'SELECT id as atruleID FROM atrule WHERE parentAtruleID = ? AND name = ?',
44 context.parentAtruleID ?? 0,
45 atruleName
46 )
47
48 await db.run(
49 'INSERT INTO atrulePosition (atruleID, position, name) VALUES (?, ?, ?)',
50 atruleID,
51 context.position++,
52 context.name
53 )
54
55 for (const n of node.nodes) {
56 await buildData(db, n, {
57 ...context,
58 position: 0,
59 parentAtruleID: atruleID
60 })
61 }
62 } else if (node.type === 'rule') {
63 if (context.pseudo) throw Error('nested rule found')
64
65 const parsed = selectorTokenizer.parse(node.selector)
66
67 await Promise.all(
68 parsed.nodes.map(async (n) => {
69 for (const nn of n.nodes) {
70 if (nn.type.startsWith('pseudo')) continue
71
72 throw Error('non-pseudo selector found')
73 }
74
75 const pseudo = selectorTokenizer.stringify(n).trim()
76
77 for (const n of node.nodes) {
78 await buildData(db, n, {...context, pseudo})
79 }
80 })
81 )
82 }
83}
84
85const run = async (args, importAndWatch) => {
86 const dbinstance = new sqlite3.Database(':memory:')
87
88 const db = {
89 exec: promisify(dbinstance.exec.bind(dbinstance)),
90 all: promisify(dbinstance.all.bind(dbinstance)),
91 get: promisify(dbinstance.get.bind(dbinstance)),
92 run: promisify(dbinstance.run.bind(dbinstance))
93 }
94
95 await db.exec(`
96 CREATE TABLE decl (
97 id INTEGER PRIMARY KEY,
98 atruleID INTEGER,
99 name TEXT,
100 pseudo TEXT,
101 prop TEXT,
102 value TEXT
103 );
104
105 CREATE TABLE atrule (
106 id INTEGER PRIMARY KEY,
107 parentAtruleID INTEGER,
108 name TEXT
109 );
110
111 CREATE TABLE atrulePosition (
112 id INTEGER PRIMARY KEY,
113 atruleID INTEGER,
114 position INTEGER,
115 name TEXT
116 );
117
118 CREATE INDEX declAtrule ON decl(atruleID);
119 CREATE INDEX atruleAtrule ON atrule(parentAtruleID);
120 CREATE UNIQUE INDEX uniqueDecl ON decl(atruleID, name, pseudo, prop);
121 CREATE UNIQUE INDEX uniqueAtrule ON atrule(parentAtruleID, name);
122 `)
123
124 const existingIDs = []
125
126 const cacheBustedInput = `${args.input}?${Date.now()}`
127
128 const input = await import(cacheBustedInput)
129
130 let inputStyles = input.styles
131
132 if (typeof inputStyles === 'function') {
133 inputStyles = await inputStyles(importAndWatch)
134 }
135
136 await mkdir(path.dirname(path.join(process.cwd(), args.output)), {
137 recursive: true
138 })
139
140 const output = {
141 css: createWriteStream(path.join(process.cwd(), `${args.output}.css`)),
142 js: createWriteStream(path.join(process.cwd(), `${args.output}.js`))
143 }
144
145 let css = ''
146
147 const map = {}
148
149 const addToMap = (name, id) => {
150 if (map[name] == null) {
151 map[name] = []
152 }
153
154 map[name].push(id)
155 }
156
157 if (input._start) {
158 css += input._start
159
160 postcss.parse(input._start).walkRules((rule) => {
161 const parsed = selectorTokenizer.parse(rule.selector)
162
163 existingIDs.push(...getClassNames(parsed))
164 })
165 }
166
167 if (input._end) {
168 postcss.parse(input._end).walkRules((rule) => {
169 const parsed = selectorTokenizer.parse(rule.selector)
170
171 existingIDs.push(...getClassNames(parsed))
172 })
173 }
174
175 const getUniqueID = createGetUniqueID(existingIDs)
176
177 const proxiedStyles = new Proxy(inputStyles, {
178 get(target, prop, receiver) {
179 if ({}.hasOwnProperty.call(target, prop)) {
180 if (typeof target[prop] === 'function') {
181 return target[prop](receiver)
182 }
183
184 return target[prop]
185 }
186
187 throw Error(`${prop} is undefined`)
188 }
189 })
190
191 for (const name of Object.keys(inputStyles)) {
192 const parsed = postcss.parse(proxiedStyles[name])
193
194 const context = {name, position: 0}
195
196 for (const node of parsed.nodes) {
197 await buildData(db, node, context)
198 }
199 }
200
201 const atrules = await db.all('SELECT * FROM atrule')
202
203 const atrulePositionsMultis = await db.all(
204 'SELECT * FROM atrulePosition WHERE name IN (SELECT name FROM atrulePosition GROUP BY name HAVING COUNT(id) > 1) ORDER BY name, position'
205 )
206
207 const atrulePositionsSingles = await db.all(
208 'SELECT * FROM atrulePosition WHERE name IN (SELECT name FROM atrulePosition GROUP BY name HAVING COUNT(id) = 1)'
209 )
210
211 const unorderedAtruleIDs = []
212 const orderedAtruleIDs = []
213
214 for (const {atruleID} of atrulePositionsSingles) {
215 unorderedAtruleIDs.push(atruleID)
216 }
217
218 let index = 0
219
220 for (const {atruleID} of atrulePositionsMultis) {
221 const unorderedIndex = unorderedAtruleIDs.indexOf(atruleID)
222
223 if (~unorderedIndex) {
224 unorderedAtruleIDs.splice(unorderedIndex, 1)
225 }
226
227 const orderedIndex = orderedAtruleIDs.indexOf(atruleID)
228
229 if (~orderedIndex) {
230 index = orderedIndex
231 } else {
232 orderedAtruleIDs.splice(++index, 0, atruleID)
233 }
234 }
235
236 const sortedAtruleIDs = unorderedAtruleIDs.concat(orderedAtruleIDs)
237
238 atrules.sort(
239 (a, b) => sortedAtruleIDs.indexOf(a.id) - sortedAtruleIDs.indexOf(b.id)
240 )
241
242 const buildCSS = async (searchID) => {
243 const singles = await db.all(
244 'SELECT *, GROUP_CONCAT(name) as names, GROUP_CONCAT(pseudo) as pseudos FROM decl WHERE atruleID = ? GROUP BY atruleID, prop, value HAVING COUNT(id) = 1 ORDER BY names, pseudos',
245 searchID
246 )
247
248 let prevSingle
249
250 if (singles.length) {
251 let id
252
253 for (const single of singles) {
254 if (single.name !== prevSingle?.name) {
255 id = getUniqueID()
256
257 addToMap(single.name, id)
258 }
259
260 if (
261 single.name !== prevSingle?.name ||
262 single.pseudo !== prevSingle?.pseudo
263 ) {
264 if (prevSingle != null) css += `} `
265
266 css += `.${id}${single.pseudo} { `
267 }
268
269 css += `${single.prop}: ${single.value}; `
270
271 prevSingle = single
272 }
273
274 css += `} `
275 }
276
277 const multis = await db.all(
278 'SELECT *, GROUP_CONCAT(name) as names, GROUP_CONCAT(pseudo) as pseudos FROM decl WHERE atruleID = ? GROUP BY atruleID, prop, value HAVING COUNT(id) > 1 ORDER BY names, pseudos',
279 searchID
280 )
281
282 let prevMulti
283
284 if (multis.length) {
285 for (const multi of multis) {
286 if (
287 prevMulti?.names !== multi.names ||
288 prevMulti?.pseudos !== multi.pseudos
289 ) {
290 const rules = await db.all(
291 'SELECT name, pseudo FROM decl WHERE atruleID = ? AND prop = ? AND value = ? ORDER BY pseudo, name',
292 multi.atruleID,
293 multi.prop,
294 multi.value
295 )
296
297 if (prevMulti != null) css += `} `
298
299 let prevPseudo
300 let id
301 const selectors = []
302
303 for (const rule of rules) {
304 if (prevPseudo !== rule.pseudo) {
305 id = getUniqueID()
306
307 selectors.push(`.${id}${rule.pseudo} `)
308 }
309
310 addToMap(rule.name, id)
311
312 prevPseudo = rule.pseudo
313 }
314
315 css += `${selectors.join(', ')} { `
316 }
317
318 css += `${multi.prop}: ${multi.value}; `
319
320 prevMulti = multi
321 }
322
323 css += `} `
324 }
325
326 for (let i = 0; i < atrules.length; i++) {
327 const {parentAtruleID, name, id} = atrules[i]
328
329 if (parentAtruleID === searchID) {
330 css += `${name} { `
331
332 await buildCSS(id)
333
334 atrules.splice(i, 1)
335
336 i--
337
338 css += '} '
339 }
340 }
341 }
342
343 await buildCSS(0)
344
345 await Promise.all(
346 Object.entries(shorthandLonghands).map(async ([shorthand, longhands]) => {
347 const rows = await db.all(
348 `SELECT decl1.name, decl1.prop as shortProp, decl2.prop as longProp
349 FROM decl as decl1
350 INNER JOIN decl as decl2 ON decl1.name = decl2.name
351 WHERE decl1.pseudo = decl2.pseudo
352 AND decl1.prop = ?
353 AND decl2.prop IN (${[...longhands].fill('?').join(', ')})
354 `,
355 shorthand,
356 ...longhands
357 )
358
359 for (const row of rows) {
360 console.warn(
361 `${row.shortProp} found with ${row.longProp} for ${row.name}`
362 )
363 }
364 })
365 )
366
367 css += input._end ?? ''
368
369 if (!args['--dev']) {
370 css = csso.minify(css, {restructure: false}).css
371 }
372
373 output.css.end(css)
374
375 for (const name of Object.keys(map)) {
376 map[name] = map[name].join(' ')
377 }
378
379 const stringifiedMap = JSON.stringify(map, null, 2)
380
381 if (args['--dev']) {
382 output.js.end(`export const classes = new Proxy(${stringifiedMap}, {
383 get(target, prop) {
384 if ({}.hasOwnProperty.call(target, prop)) {
385 return target[prop]
386 }
387
388 throw Error(\`\${prop} is undefined\`)
389 }
390 })`)
391 } else {
392 output.js.end(`export const classes = ${stringifiedMap}`)
393 }
394
395 dbinstance.close()
396
397 return Promise.all(
398 ['css', 'js'].map((type) =>
399 finished(output[type]).then(() => {
400 process.stdout.write(`${gray('[css]')} saved ${args.output}.${type}\n`)
401 })
402 )
403 )
404}
405
406export default async (args) => {
407 args.input = path.join(process.cwd(), args.input)
408
409 let importAndWatch = (file) => {
410 return import(path.join(path.dirname(args.input), file))
411 }
412
413 if (!args['--watch']) {
414 return run(args, importAndWatch)
415 }
416
417 const watcher = chokidar.watch(args.input, {ignoreInitial: true})
418
419 const imported = []
420
421 importAndWatch = (file) => {
422 file = path.join(path.dirname(args.input), file)
423
424 imported.push(file)
425
426 watcher.add(file)
427
428 return import(`${file}?${Date.now()}`)
429 }
430
431 run(args, importAndWatch)
432
433 watcher.on('change', () => {
434 watcher.unwatch(imported)
435
436 imported.splice(0, imported.length)
437
438 run(args, importAndWatch)
439 })
440}