Tiny script for preparing web assets for deployment
at master 8.9 kB view raw
1#!/usr/bin/env node 2 3import fs from 'fs'; 4import path from 'path'; 5import child_process from 'child_process'; 6import uglifyjs from "uglify-js"; 7import * as sass from 'sass'; 8import * as csso from 'csso'; 9 10const spawn = child_process.spawn; 11const fsp = fs.promises; 12const STYLESDIR = 'styles'; 13const SCRIPTSDIR = 'scripts'; 14const IMAGESDIR = path.join(process.cwd(), 'assets', 'images', 'original'); 15const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join('assets', 'css'); 16const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join('assets', 'js'); 17const WEBPOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'images', 'webp'); 18const AVIFOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'images', 'avif'); 19const STYLEOUTFILE = process.env.STYLEOUTFILE || 'styles.css'; 20const SQUASH = new RegExp('^[0-9]+-'); 21 22async function emptyDir(dir: string) { 23 await Promise.all((await fsp.readdir(dir, { withFileTypes: true })).map(f => path.join(dir, f.name)).map(p => fsp.rm(p, { 24 recursive: true, 25 force: true 26 }))); 27 return true; 28} 29 30async function mkdir(dir: string | string[]) { 31 if (typeof dir === 'string') { 32 await fsp.mkdir(dir, { recursive: true }); 33 } 34 else { 35 await Promise.all(dir.map(mkdir)); 36 } 37 return true; 38} 39 40function getFileExtension(filename: string) { 41 const split = filename.split('.'); 42 return split[split.length - 1].toLowerCase(); 43} 44 45// Process styles 46async function styles() { 47 await mkdir([STYLEOUTDIR, STYLESDIR]); 48 await emptyDir(STYLEOUTDIR); 49 const styles: string[] = []; 50 const files = await fsp.readdir(STYLESDIR); 51 await Promise.all(files.map(f => new Promise(async (res, reject) => { 52 const p = path.join(STYLESDIR, f); 53 console.log(`Processing style ${p}`); 54 const style = sass.compile(p).css; 55 if (f.charAt(0) !== '_') { 56 if (SQUASH.test(f)) { 57 styles.push(style); 58 } 59 else { 60 const o = path.join(STYLEOUTDIR, f.substring(0, f.lastIndexOf('.')) + '.css'); 61 await fsp.writeFile(o, csso.minify(style).css); 62 console.log(`Wrote ${o}`); 63 } 64 } 65 res(0); 66 }))); 67 const out = csso.minify(styles.join('\n')).css; 68 const outpath = path.join(STYLEOUTDIR, STYLEOUTFILE); 69 await fsp.writeFile(outpath, out); 70 console.log(`Wrote ${outpath}`); 71} 72 73// Process scripts 74async function scripts() { 75 await mkdir([SCRIPTSOUTDIR, SCRIPTSDIR]); 76 await emptyDir(SCRIPTSOUTDIR); 77 const files = await fsp.readdir(SCRIPTSDIR); 78 await Promise.all(files.filter(f => f.toLowerCase().endsWith('.js')).map(f => new Promise(async (res, _) => { 79 const p = path.join(SCRIPTSDIR, f); 80 const o = path.join(SCRIPTSOUTDIR, f); 81 console.log(`Processing script ${p}`); 82 try { 83 await fsp.writeFile(o, uglifyjs.minify((await fsp.readFile(p)).toString()).code); 84 console.log(`Wrote ${o}`); 85 } 86 catch (ex) { 87 console.log(`error writing ${o}: ${ex}`); 88 } 89 res(0); 90 }))); 91} 92 93async function getAllFiles(fullDir: string): Promise<string[]> { 94 if (!path.isAbsolute(fullDir)) { 95 throw new Error('path must be absolute'); 96 } 97 const files: string[] = []; 98 const dirs = ['']; 99 for (let i = 0; i < dirs.length; i++) { 100 const parent = dirs[i]; 101 const dir = path.join(fullDir, parent); 102 const dirEnts = await fsp.readdir(dir, { withFileTypes: true }); 103 dirEnts.forEach(de => (de.isDirectory() ? dirs : files).push(path.join(parent, de.name))); 104 } 105 return files; 106} 107 108// Process images 109async function images(webp: boolean, avif: boolean, dir: string = IMAGESDIR) { 110 await mkdir(dir); 111 await mkdir(WEBPOUTDIR) && await emptyDir(WEBPOUTDIR); 112 await mkdir(AVIFOUTDIR) && await emptyDir(AVIFOUTDIR); 113 const releativeFiles = await getAllFiles(dir); 114 if (releativeFiles.length) { 115 await Promise.all(releativeFiles.map(f => processImage(dir, f, webp, avif))); 116 } 117} 118 119async function processImage(parentDir: string, relativeFile: string, webp: boolean, avif: boolean) { 120 const infile = path.join(parentDir, relativeFile); 121 const dir = path.dirname(relativeFile); 122 const outDirWebP = path.join(WEBPOUTDIR, dir); 123 const outDirAvif = path.join(AVIFOUTDIR, dir); 124 webp && await mkdir(outDirWebP); 125 avif && await mkdir(outDirAvif); 126 console.log(`Processing image ${infile}`); 127 webp && await convertWebP(infile, outDirWebP); 128 avif && await convertAvif(infile, outDirAvif); 129} 130 131function convertWebP(infile: string, outDir: string) { 132 return new Promise((resolve, reject) => { 133 const filename = path.basename(infile); 134 const extension = getFileExtension(filename); 135 const outfile = path.join(outDir, filename.substring(0, filename.lastIndexOf('.')) + '.webp'); 136 const libwebpArgs = ['-mt']; 137 if (extension === 'jpeg' || extension === 'jpg') { 138 libwebpArgs.push('-q', '60'); 139 } 140 else { 141 libwebpArgs.push('-near_lossless', '55'); 142 } 143 libwebpArgs.push(infile, '-o', outfile); 144 const proc = spawn('cwebp', libwebpArgs); 145 const timeout = setTimeout(() => { 146 proc.kill(); 147 reject(new Error(`process timed out`)); 148 }, parseInt(process.env['CWEBPTIMEOUT']) || 30000); 149 proc.on('exit', async (code) => { 150 clearTimeout(timeout); 151 if (code === 0) { 152 console.log(`Wrote ${outfile}`); 153 resolve(true); 154 } 155 else { 156 reject(new Error(`process ended with code ${code}`)); 157 } 158 }); 159 }); 160} 161 162function convertAvif(infile: string, outDir: string) { 163 return new Promise((resolve, reject) => { 164 const filename = path.basename(infile); 165 const extension = getFileExtension(filename); 166 const outfile = path.join(outDir, filename.substring(0, filename.lastIndexOf('.')) + '.avif'); 167 const avifencArgs = '--speed 6 --jobs all --depth 8 --cicp 1/13/6 --codec aom'.split(' '); 168 if (extension === 'jpeg' || extension === 'jpg') { 169 avifencArgs.push('--advanced', 'cq-level=28', '-q', '40', '--yuv', '420'); 170 } 171 else { 172 avifencArgs.push('--advanced', 'cq-level=30', '-q', '45', '--yuv', '444'); 173 } 174 avifencArgs.push(infile, outfile); 175 console.log(`avifenc ${avifencArgs.join(' ')}`); 176 const proc = spawn('avifenc', avifencArgs); 177 const timeout = setTimeout(() => { 178 proc.kill(); 179 reject(new Error(`process timed out`)); 180 }, parseInt(process.env['AVIFENCTIMEOUT']) || 30000); 181 proc.on('exit', async (code) => { 182 clearTimeout(timeout); 183 if (code === 0) { 184 console.log(`Wrote ${outfile}`); 185 resolve(true); 186 } 187 else { 188 reject(new Error(`process ended with code ${code}`)); 189 } 190 }); 191 }); 192} 193 194function commandExists(cmd: string): Promise<boolean> { 195 return new Promise((resolve, _) => { 196 const proc = spawn('which', cmd.split(' ')); 197 proc.on('exit', async (code) => resolve(code === 0)); 198 }); 199} 200 201function isAbortError(err: unknown): boolean { 202 return typeof err === 'object' && err !== null && 'name' in err && err.name === 'AbortError'; 203} 204 205(async function () { 206 const webp = await commandExists('cwebp'); 207 const avif = await commandExists('avifenc'); 208 if (!webp && ! avif) { 209 console.error('WARNING: no image encoding software found.'); 210 } 211 await Promise.all([styles(), scripts(), images(webp, avif)]); 212 if (process.argv.indexOf('--watch') >= 0) { 213 console.log('watching for changes...'); 214 (async () => { 215 try { 216 const watcher = fsp.watch(STYLESDIR); 217 for await (const _ of watcher) 218 await styles(); 219 } catch (err) { 220 if (isAbortError(err)) 221 return; 222 throw err; 223 } 224 })(); 225 226 (async () => { 227 try { 228 const watcher = fsp.watch(SCRIPTSDIR); 229 for await (const _ of watcher) 230 await scripts(); 231 } catch (err) { 232 if (isAbortError(err)) 233 return; 234 throw err; 235 } 236 })(); 237 238 (async () => { 239 try { 240 const watcher = fsp.watch(IMAGESDIR, { 241 recursive: true // no Linux ☹️ 242 }); 243 for await (const _ of watcher) 244 await images(webp, avif); 245 } catch (err) { 246 if (isAbortError(err)) 247 return; 248 throw err; 249 } 250 })(); 251 } 252})();