UNPKG

10.3 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) => {
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 await mkdir(path.dirname(path.join(process.cwd(), args.output)), {
131 recursive: true
132 })
133
134 const output = {
135 css: createWriteStream(path.join(process.cwd(), `${args.output}.css`)),
136 js: createWriteStream(path.join(process.cwd(), `${args.output}.js`))
137 }
138
139 let css = ''
140
141 const map = {}
142
143 const addToMap = (name, id) => {
144 if (map[name] == null) {
145 map[name] = []
146 }
147
148 map[name].push(id)
149 }
150
151 if (input._start) {
152 css += input._start
153
154 postcss.parse(input._start).walkRules((rule) => {
155 const parsed = selectorTokenizer.parse(rule.selector)
156
157 existingIDs.push(...getClassNames(parsed))
158 })
159 }
160
161 if (input._end) {
162 postcss.parse(input._end).walkRules((rule) => {
163 const parsed = selectorTokenizer.parse(rule.selector)
164
165 existingIDs.push(...getClassNames(parsed))
166 })
167 }
168
169 const getUniqueID = createGetUniqueID(existingIDs)
170
171 const proxiedStyles = new Proxy(input.styles, {
172 get(target, prop, receiver) {
173 if ({}.hasOwnProperty.call(target, prop)) {
174 if (typeof target[prop] === 'function') {
175 return target[prop](receiver)
176 }
177
178 return target[prop]
179 }
180
181 throw Error(`${prop} is undefined`)
182 }
183 })
184
185 for (const name of Object.keys(input.styles)) {
186 const parsed = postcss.parse(proxiedStyles[name])
187
188 const context = {name, position: 0}
189
190 for (const node of parsed.nodes) {
191 await buildData(db, node, context)
192 }
193 }
194
195 const atrules = await db.all('SELECT * FROM atrule')
196
197 const atrulePositionsMultis = await db.all(
198 'SELECT * FROM atrulePosition WHERE name IN (SELECT name FROM atrulePosition GROUP BY name HAVING COUNT(id) > 1) ORDER BY name, position'
199 )
200
201 const atrulePositionsSingles = await db.all(
202 'SELECT * FROM atrulePosition WHERE name IN (SELECT name FROM atrulePosition GROUP BY name HAVING COUNT(id) = 1)'
203 )
204
205 const unorderedAtruleIDs = []
206 const orderedAtruleIDs = []
207
208 for (const {atruleID} of atrulePositionsSingles) {
209 unorderedAtruleIDs.push(atruleID)
210 }
211
212 let index = 0
213
214 for (const {atruleID} of atrulePositionsMultis) {
215 const unorderedIndex = unorderedAtruleIDs.indexOf(atruleID)
216
217 if (~unorderedIndex) {
218 unorderedAtruleIDs.splice(unorderedIndex, 1)
219 }
220
221 const orderedIndex = orderedAtruleIDs.indexOf(atruleID)
222
223 if (~orderedIndex) {
224 index = orderedIndex
225 } else {
226 orderedAtruleIDs.splice(++index, 0, atruleID)
227 }
228 }
229
230 const sortedAtruleIDs = unorderedAtruleIDs.concat(orderedAtruleIDs)
231
232 atrules.sort(
233 (a, b) => sortedAtruleIDs.indexOf(a.id) - sortedAtruleIDs.indexOf(b.id)
234 )
235
236 const buildCSS = async (searchID) => {
237 const singles = await db.all(
238 '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',
239 searchID
240 )
241
242 let prevSingle
243
244 if (singles.length) {
245 let id
246
247 for (const single of singles) {
248 if (single.name !== prevSingle?.name) {
249 id = getUniqueID()
250
251 addToMap(single.name, id)
252 }
253
254 if (
255 single.name !== prevSingle?.name ||
256 single.pseudo !== prevSingle?.pseudo
257 ) {
258 if (prevSingle != null) css += `} `
259
260 css += `.${id}${single.pseudo} { `
261 }
262
263 css += `${single.prop}: ${single.value}; `
264
265 prevSingle = single
266 }
267
268 css += `} `
269 }
270
271 const multis = await db.all(
272 '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',
273 searchID
274 )
275
276 let prevMulti
277
278 if (multis.length) {
279 for (const multi of multis) {
280 if (
281 prevMulti?.names !== multi.names ||
282 prevMulti?.pseudos !== multi.pseudos
283 ) {
284 const rules = await db.all(
285 'SELECT name, pseudo FROM decl WHERE atruleID = ? AND prop = ? AND value = ? ORDER BY pseudo, name',
286 multi.atruleID,
287 multi.prop,
288 multi.value
289 )
290
291 if (prevMulti != null) css += `} `
292
293 let prevPseudo
294 let id
295 const selectors = []
296
297 for (const rule of rules) {
298 if (prevPseudo !== rule.pseudo) {
299 id = getUniqueID()
300
301 selectors.push(`.${id}${rule.pseudo} `)
302 }
303
304 addToMap(rule.name, id)
305
306 prevPseudo = rule.pseudo
307 }
308
309 css += `${selectors.join(', ')} { `
310 }
311
312 css += `${multi.prop}: ${multi.value}; `
313
314 prevMulti = multi
315 }
316
317 css += `} `
318 }
319
320 for (let i = 0; i < atrules.length; i++) {
321 const {parentAtruleID, name, id} = atrules[i]
322
323 if (parentAtruleID === searchID) {
324 css += `${name} { `
325
326 await buildCSS(id)
327
328 atrules.splice(i, 1)
329
330 i--
331
332 css += '} '
333 }
334 }
335 }
336
337 await buildCSS(0)
338
339 await Promise.all(
340 Object.entries(shorthandLonghands).map(async ([shorthand, longhands]) => {
341 const rows = await db.all(
342 `SELECT decl1.name, decl1.prop as shortProp, decl2.prop as longProp
343 FROM decl as decl1
344 INNER JOIN decl as decl2 ON decl1.name = decl2.name
345 WHERE decl1.pseudo = decl2.pseudo
346 AND decl1.prop = ?
347 AND decl2.prop IN (${[...longhands].fill('?').join(', ')})
348 `,
349 shorthand,
350 ...longhands
351 )
352
353 for (const row of rows) {
354 console.warn(
355 `${row.shortProp} found with ${row.longProp} for ${row.name}`
356 )
357 }
358 })
359 )
360
361 css += input._end ?? ''
362
363 if (!args['--dev']) {
364 css = csso.minify(css, {restructure: false}).css
365 }
366
367 output.css.end(css)
368
369 for (const name of Object.keys(map)) {
370 map[name] = map[name].join(' ')
371 }
372
373 const stringifiedMap = JSON.stringify(map, null, 2)
374
375 if (args['--dev']) {
376 output.js.end(`export const classes = new Proxy(${stringifiedMap}, {
377 get(target, prop) {
378 if ({}.hasOwnProperty.call(target, prop)) {
379 return target[prop]
380 }
381
382 throw Error(\`\${prop} is undefined\`)
383 }
384 })`)
385 } else {
386 output.js.end(`export const classes = ${stringifiedMap}`)
387 }
388
389 dbinstance.close()
390
391 return Promise.all(
392 ['css', 'js'].map((type) =>
393 finished(output[type]).then(() => {
394 process.stdout.write(`${gray('[css]')} saved ${args.output}.${type}\n`)
395 })
396 )
397 )
398}
399
400export default async (args) => {
401 args.input = path.join(process.cwd(), args.input)
402
403 if (!args['--watch']) {
404 return run(args)
405 }
406
407 run(args)
408
409 chokidar.watch(args.input, {ignoreInitial: true}).on('change', () => {
410 run(args)
411 })
412}