Tiny script for preparing web assets for deployment
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})();