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('assets', 'images', 'original');
15const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join('assets', 'css');
16const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join('assets', 'js');
17const IMAGESOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'images', 'webp');
18const STYLEOUTFILE = process.env.STYLEOUTFILE || 'styles.css';
19const SQUASH = new RegExp('^[0-9]+-');
20
21async function emptyDir(dir: string) {
22 await Promise.all((await fsp.readdir(dir, { withFileTypes: true })).map(f => path.join(dir, f.name)).map(p => fsp.rm(p, {
23 recursive: true,
24 force: true
25 })));
26}
27
28async function mkdir(dir: string | string[]) {
29 if (typeof dir === 'string') {
30 await fsp.mkdir(dir, { recursive: true });
31 }
32 else {
33 await Promise.all(dir.map(mkdir));
34 }
35}
36
37function getFileExtension(filename: string) {
38 const split = filename.split('.');
39 return split[split.length - 1].toLowerCase();
40}
41
42// Process styles
43async function styles() {
44 await mkdir([STYLEOUTDIR, STYLESDIR]);
45 await emptyDir(STYLEOUTDIR);
46 const styles: string[] = [];
47 const files = await fsp.readdir(STYLESDIR);
48 await Promise.all(files.map(f => new Promise(async (res, reject) => {
49 const p = path.join(STYLESDIR, f);
50 console.log(`Processing style ${p}`);
51 const style = sass.compile(p).css;
52 if (f.charAt(0) !== '_') {
53 if (SQUASH.test(f)) {
54 styles.push(style);
55 }
56 else {
57 const o = path.join(STYLEOUTDIR, f.substring(0, f.lastIndexOf('.')) + '.css');
58 await fsp.writeFile(o, csso.minify(style).css);
59 console.log(`Wrote ${o}`);
60 }
61 }
62 res(0);
63 })));
64 const out = csso.minify(styles.join('\n')).css;
65 const outpath = path.join(STYLEOUTDIR, STYLEOUTFILE);
66 await fsp.writeFile(outpath, out);
67 console.log(`Wrote ${outpath}`);
68}
69
70// Process scripts
71async function scripts() {
72 await mkdir([SCRIPTSOUTDIR, SCRIPTSDIR]);
73 await emptyDir(SCRIPTSOUTDIR);
74 const files = await fsp.readdir(SCRIPTSDIR);
75 await Promise.all(files.map(f => new Promise(async (res, reject) => {
76 const p = path.join(SCRIPTSDIR, f);
77 const o = path.join(SCRIPTSOUTDIR, f);
78 console.log(`Processing script ${p}`);
79 try {
80 await fsp.writeFile(o, uglifyjs.minify((await fsp.readFile(p)).toString()).code);
81 console.log(`Wrote ${o}`);
82 }
83 catch (ex) {
84 console.log(`error writing ${o}: ${ex}`);
85 }
86 res(0);
87 })));
88}
89
90// Process images
91async 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);
97 }
98 const files = await fsp.readdir(p, {
99 withFileTypes: true
100 });
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 });
133 }
134 else if (f.isDirectory()) {
135 images(path.join(dir, f.name)).then(res).catch(reject);
136 }
137 })));
138 }
139}
140
141function isAbortError(err: unknown): boolean {
142 return typeof err === 'object' && err !== null && 'name' in err && err.name === 'AbortError';
143}
144
145(async function () {
146 await Promise.all([styles(), scripts(), images()]);
147 if (process.argv.indexOf('--watch') >= 0) {
148 console.log('watching for changes...');
149 (async () => {
150 try {
151 const watcher = fsp.watch(STYLESDIR);
152 for await (const _ of watcher)
153 await styles();
154 } catch (err) {
155 if (isAbortError(err))
156 return;
157 throw err;
158 }
159 })();
160
161 (async () => {
162 try {
163 const watcher = fsp.watch(SCRIPTSDIR);
164 for await (const _ of watcher)
165 await scripts();
166 } catch (err) {
167 if (isAbortError(err))
168 return;
169 throw err;
170 }
171 })();
172
173 (async () => {
174 try {
175 const watcher = fsp.watch(IMAGESDIR, {
176 recursive: true // no Linux ☹️
177 });
178 for await (const _ of watcher)
179 await images();
180 } catch (err) {
181 if (isAbortError(err))
182 return;
183 throw err;
184 }
185 })();
186 }
187})();