Tiny script for preparing web assets for deployment

Add AVIF support, flatten recursion for image processing

Changed files
+113 -48
src
+113 -48
src/build-shit.ts
··· 11 11 const fsp = fs.promises; 12 12 const STYLESDIR = 'styles'; 13 13 const SCRIPTSDIR = 'scripts'; 14 - const IMAGESDIR = path.join('assets', 'images', 'original'); 14 + const IMAGESDIR = path.join(process.cwd(), 'assets', 'images', 'original'); 15 15 const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join('assets', 'css'); 16 16 const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join('assets', 'js'); 17 - const IMAGESOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'images', 'webp'); 17 + const WEBPOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'images', 'webp'); 18 + const AVIFOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'images', 'avif'); 18 19 const STYLEOUTFILE = process.env.STYLEOUTFILE || 'styles.css'; 19 20 const SQUASH = new RegExp('^[0-9]+-'); 20 21 ··· 23 24 recursive: true, 24 25 force: true 25 26 }))); 27 + return true; 26 28 } 27 29 28 30 async function mkdir(dir: string | string[]) { ··· 32 34 else { 33 35 await Promise.all(dir.map(mkdir)); 34 36 } 37 + return true; 35 38 } 36 39 37 40 function getFileExtension(filename: string) { ··· 87 90 }))); 88 91 } 89 92 93 + async 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 + 90 108 // Process images 91 - async function images(dir = '') { 92 - const p = path.join(IMAGESDIR, dir); 93 - await mkdir(p); 94 - if (dir.length === 0) { 95 - await mkdir(IMAGESOUTDIR) 96 - await emptyDir(IMAGESOUTDIR); 109 + async 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))); 97 116 } 98 - const files = await fsp.readdir(p, { 99 - withFileTypes: true 117 + } 118 + 119 + async 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 + 131 + function 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 + }); 100 159 }); 101 - if (files.length) { 102 - await Promise.all(files.map(f => new Promise(async (res, reject) => { 103 - if (f.isFile()) { 104 - const outDir = path.join(IMAGESOUTDIR, dir); 105 - const infile = path.join(p, f.name); 106 - const extension = getFileExtension(infile); 107 - const outfile = path.join(outDir, f.name.substring(0, f.name.lastIndexOf('.')) + '.webp'); 108 - await mkdir(outDir); 109 - console.log(`Processing image ${infile}`) 110 - const libwebpArgs = ['-mt']; 111 - if (extension === 'jpeg' || extension === 'jpg') { 112 - libwebpArgs.push('-q', '60'); 113 - } 114 - else { 115 - libwebpArgs.push('-near_lossless', '55'); 116 - } 117 - libwebpArgs.push(infile, '-o', outfile); 118 - const proc = spawn('cwebp', libwebpArgs); 119 - const timeout = setTimeout(() => { 120 - reject('Timed out'); 121 - proc.kill(); 122 - }, parseInt(process.env['CWEBPTIMEOUT']) || 30000); 123 - proc.on('exit', async (code) => { 124 - clearTimeout(timeout); 125 - if (code === 0) { 126 - console.log(`Wrote ${outfile}`); 127 - res(null); 128 - } 129 - else { 130 - reject(code); 131 - } 132 - }); 160 + } 161 + 162 + function 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); 133 186 } 134 - else if (f.isDirectory()) { 135 - images(path.join(dir, f.name)).then(res).catch(reject); 187 + else { 188 + reject(new Error(`process ended with code ${code}`)); 136 189 } 137 - }))); 138 - } 190 + }); 191 + }); 192 + } 193 + 194 + function 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 + }); 139 199 } 140 200 141 201 function isAbortError(err: unknown): boolean { ··· 143 203 } 144 204 145 205 (async function () { 146 - await Promise.all([styles(), scripts(), images()]); 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)]); 147 212 if (process.argv.indexOf('--watch') >= 0) { 148 213 console.log('watching for changes...'); 149 214 (async () => { ··· 176 241 recursive: true // no Linux ☹️ 177 242 }); 178 243 for await (const _ of watcher) 179 - await images(); 244 + await images(webp, avif); 180 245 } catch (err) { 181 246 if (isAbortError(err)) 182 247 return;