UNPKG

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