Tiny script for preparing web assets for deployment

Compare changes

Choose any two refs to compare.

Changed files
+199 -88
.tangled
workflows
src
+25
.tangled/workflows/npm_publish.yml
··· 1 + when: 2 + - event: ["push"] 3 + branch: ["master"] 4 + 5 + engine: "nixery" 6 + 7 + dependencies: 8 + nixpkgs: 9 + - nodejs 10 + - gnugrep 11 + 12 + steps: 13 + - name: "Install dependencies" 14 + command: "npm install" 15 + 16 + - name: "tsc" 17 + command: "npx tsc && echo 'done.'" 18 + 19 + - name: "npm publish" 20 + command: "git log -1 --pretty=%B | grep -q '^publish new version' && npm set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm publish || exit 0" 21 + 22 + clone: 23 + skip: false 24 + depth: 3 25 + submodules: false
+7 -2
README.md
··· 1 1 # build-shit 2 - A build script for preparing files for production. It handles CSS and JavaScript minification, Sass compilation, and image conversion to WebP. 2 + 3 + [![GitHub License](https://img.shields.io/github/license/CorySanin/build-shit)](https://tangled.sh/@sanin.dev/build-shit/blob/master/LICENSE) 4 + [![NPM Version](https://img.shields.io/npm/v/forking-build-shit)](https://www.npmjs.com/package/forking-build-shit) 5 + [![NPM Unpacked Size](https://img.shields.io/npm/unpacked-size/forking-build-shit)](https://www.npmjs.com/package/forking-build-shit) 6 + 7 + A build script for preparing files for production. It handles CSS and JavaScript minification, Sass compilation, and image conversion to WebP and AVIF. 3 8 4 - WebP conversion requires libwebp. 9 + WebP conversion requires libwebp. AVIF conversion requires avifenc. 5 10 6 11 ## Installation 7 12
+25 -24
package-lock.json
··· 1 1 { 2 2 "name": "forking-build-shit", 3 - "version": "0.0.1", 3 + "version": "1.0.5", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "forking-build-shit", 9 - "version": "0.0.1", 9 + "version": "1.0.5", 10 10 "license": "MIT", 11 11 "dependencies": { 12 12 "csso": "5.0.5", 13 - "sass": "1.86.0", 13 + "sass": "1.93.2", 14 14 "uglify-js": "3.19.3" 15 15 }, 16 16 "bin": { 17 - "build-shit": "bin/build-shit.js" 17 + "build-shit": "bin/build-shit.js", 18 + "forking-build-shit": "bin/build-shit.js" 18 19 }, 19 20 "devDependencies": { 20 - "@sindresorhus/tsconfig": "7.0.0", 21 + "@sindresorhus/tsconfig": "8.0.1", 21 22 "@types/csso": "^5.0.4", 22 - "@types/node": "^22.13.10", 23 + "@types/node": "^24.7.2", 23 24 "@types/uglify-js": "^3.17.5", 24 - "typescript": "5.8.2" 25 + "typescript": "5.9.3" 25 26 } 26 27 }, 27 28 "node_modules/@parcel/watcher": { ··· 321 322 } 322 323 }, 323 324 "node_modules/@sindresorhus/tsconfig": { 324 - "version": "7.0.0", 325 - "resolved": "https://registry.npmjs.org/@sindresorhus/tsconfig/-/tsconfig-7.0.0.tgz", 326 - "integrity": "sha512-i5K04hLAP44Af16zmDjG07E1NHuDgCM07SJAT4gY0LZSRrWYzwt4qkLem6TIbIVh0k51RkN2bF+lP+lM5eC9fw==", 325 + "version": "8.0.1", 326 + "resolved": "https://registry.npmjs.org/@sindresorhus/tsconfig/-/tsconfig-8.0.1.tgz", 327 + "integrity": "sha512-EcJpJuPR+Ot2DGJwQNRMVrWMxiYluGEQrgHeFHvKkKJcHCL/J3fmAKtN5WmAHIN7oxtwSEvNfjJgwffmxKBw9Q==", 327 328 "dev": true, 328 329 "license": "MIT", 329 330 "engines": { 330 - "node": ">=18" 331 + "node": ">=20" 331 332 }, 332 333 "funding": { 333 334 "url": "https://github.com/sponsors/sindresorhus" ··· 351 352 } 352 353 }, 353 354 "node_modules/@types/node": { 354 - "version": "22.13.10", 355 - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", 356 - "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", 355 + "version": "24.7.2", 356 + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", 357 + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", 357 358 "dev": true, 358 359 "license": "MIT", 359 360 "dependencies": { 360 - "undici-types": "~6.20.0" 361 + "undici-types": "~7.14.0" 361 362 } 362 363 }, 363 364 "node_modules/@types/uglify-js": { ··· 544 545 } 545 546 }, 546 547 "node_modules/sass": { 547 - "version": "1.86.0", 548 - "resolved": "https://registry.npmjs.org/sass/-/sass-1.86.0.tgz", 549 - "integrity": "sha512-zV8vGUld/+mP4KbMLJMX7TyGCuUp7hnkOScgCMsWuHtns8CWBoz+vmEhoGMXsaJrbUP8gj+F1dLvVe79sK8UdA==", 548 + "version": "1.93.2", 549 + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", 550 + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", 550 551 "license": "MIT", 551 552 "dependencies": { 552 553 "chokidar": "^4.0.0", ··· 596 597 } 597 598 }, 598 599 "node_modules/typescript": { 599 - "version": "5.8.2", 600 - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", 601 - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", 600 + "version": "5.9.3", 601 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 602 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 602 603 "dev": true, 603 604 "license": "Apache-2.0", 604 605 "bin": { ··· 622 623 } 623 624 }, 624 625 "node_modules/undici-types": { 625 - "version": "6.20.0", 626 - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", 627 - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", 626 + "version": "7.14.0", 627 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", 628 + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", 628 629 "dev": true, 629 630 "license": "MIT" 630 631 }
+11 -10
package.json
··· 1 1 { 2 2 "name": "forking-build-shit", 3 - "version": "0.0.1", 3 + "version": "1.0.5", 4 4 "description": "Tiny script for preparing web assets for deployment", 5 - "homepage": "https://github.com/CorySanin/build-shit#readme", 5 + "homepage": "https://tangled.org/@sanin.dev/build-shit#readme", 6 6 "bugs": { 7 - "url": "https://github.com/CorySanin/build-shit/issues" 7 + "url": "https://tangled.org/@sanin.dev/build-shit/issues" 8 8 }, 9 9 "dependencies": { 10 10 "csso": "5.0.5", 11 - "sass": "1.86.0", 11 + "sass": "1.93.2", 12 12 "uglify-js": "3.19.3" 13 13 }, 14 14 "devDependencies": { 15 - "@sindresorhus/tsconfig": "7.0.0", 15 + "@sindresorhus/tsconfig": "8.0.1", 16 16 "@types/csso": "^5.0.4", 17 - "@types/node": "^22.13.10", 17 + "@types/node": "^24.7.2", 18 18 "@types/uglify-js": "^3.17.5", 19 - "typescript": "5.8.2" 19 + "typescript": "5.9.3" 20 20 }, 21 21 "repository": { 22 22 "type": "git", 23 - "url": "git+https://github.com/CorySanin/build-shit.git" 23 + "url": "git+https://tangled.org/@sanin.dev/build-shit" 24 24 }, 25 25 "license": "MIT", 26 26 "author": { 27 27 "name": "Cory Sanin", 28 - "email": "corysanin@artixlinux.org", 28 + "email": "corysanin@outlook.com", 29 29 "url": "https://sanin.dev" 30 30 }, 31 31 "type": "module", 32 32 "main": "bin/build-shit.js", 33 33 "bin": { 34 - "build-shit": "./bin/build-shit.js" 34 + "build-shit": "./bin/build-shit.js", 35 + "forking-build-shit": "./bin/build-shit.js" 35 36 }, 36 37 "scripts": { 37 38 "build": "tsc"
+131 -52
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'); 15 - const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join(import.meta.dirname, 'assets', 'css'); 16 - const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join(import.meta.dirname, 'assets', 'js'); 17 - const IMAGESOUTDIR = process.env.IMAGESOUTDIR || path.join(import.meta.dirname, 'assets', 'images', 'webp'); 14 + const IMAGESDIR = path.join(process.cwd(), 'assets', 'images', 'original'); 15 + const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join('assets', 'css'); 16 + const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join('assets', 'js'); 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; 38 + } 39 + 40 + function getFileExtension(filename: string) { 41 + const split = filename.split('.'); 42 + return split[split.length - 1].toLowerCase(); 35 43 } 36 44 37 45 // Process styles 38 46 async function styles() { 39 47 await mkdir([STYLEOUTDIR, STYLESDIR]); 40 48 await emptyDir(STYLEOUTDIR); 41 - let styles: string[] = []; 42 - let files = await fsp.readdir(STYLESDIR); 49 + const styles: string[] = []; 50 + const files = await fsp.readdir(STYLESDIR); 43 51 await Promise.all(files.map(f => new Promise(async (res, reject) => { 44 - let p = path.join(STYLESDIR, f); 52 + const p = path.join(STYLESDIR, f); 45 53 console.log(`Processing style ${p}`); 46 - let style = sass.compile(p).css; 54 + const style = sass.compile(p).css; 47 55 if (f.charAt(0) !== '_') { 48 56 if (SQUASH.test(f)) { 49 57 styles.push(style); 50 58 } 51 59 else { 52 - let o = path.join(STYLEOUTDIR, f.substring(0, f.lastIndexOf('.')) + '.css'); 60 + const o = path.join(STYLEOUTDIR, f.substring(0, f.lastIndexOf('.')) + '.css'); 53 61 await fsp.writeFile(o, csso.minify(style).css); 54 62 console.log(`Wrote ${o}`); 55 63 } 56 64 } 57 65 res(0); 58 66 }))); 59 - let out = csso.minify(styles.join('\n')).css; 60 - let outpath = path.join(STYLEOUTDIR, STYLEOUTFILE); 67 + const out = csso.minify(styles.join('\n')).css; 68 + const outpath = path.join(STYLEOUTDIR, STYLEOUTFILE); 61 69 await fsp.writeFile(outpath, out); 62 70 console.log(`Wrote ${outpath}`); 63 71 } ··· 66 74 async function scripts() { 67 75 await mkdir([SCRIPTSOUTDIR, SCRIPTSDIR]); 68 76 await emptyDir(SCRIPTSOUTDIR); 69 - let files = await fsp.readdir(SCRIPTSDIR); 70 - await Promise.all(files.map(f => new Promise(async (res, reject) => { 71 - let p = path.join(SCRIPTSDIR, f); 72 - let o = path.join(SCRIPTSOUTDIR, f); 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); 73 81 console.log(`Processing script ${p}`); 74 82 try { 75 83 await fsp.writeFile(o, uglifyjs.minify((await fsp.readFile(p)).toString()).code); ··· 82 90 }))); 83 91 } 84 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 + 85 108 // Process images 86 - async function images(dir = '') { 87 - let p = path.join(IMAGESDIR, dir); 88 - await mkdir(p); 89 - if (dir.length === 0) { 90 - await mkdir(IMAGESOUTDIR) 91 - 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))); 92 116 } 93 - let files = await fsp.readdir(p, { 94 - 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 + }); 95 159 }); 96 - if (files.length) { 97 - await Promise.all(files.map(f => new Promise(async (res, reject) => { 98 - if (f.isFile()) { 99 - let outDir = path.join(IMAGESOUTDIR, dir); 100 - let infile = path.join(p, f.name); 101 - let outfile = path.join(outDir, f.name.substring(0, f.name.lastIndexOf('.')) + '.webp'); 102 - await mkdir(outDir); 103 - console.log(`Processing image ${infile}`) 104 - let process = spawn('cwebp', ['-mt', '-q', '50', infile, '-o', outfile]); 105 - let timeout = setTimeout(() => { 106 - reject('Timed out'); 107 - process.kill(); 108 - }, 30000); 109 - process.on('exit', async (code) => { 110 - clearTimeout(timeout); 111 - if (code === 0) { 112 - console.log(`Wrote ${outfile}`); 113 - res(null); 114 - } 115 - else { 116 - reject(code); 117 - } 118 - }); 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); 119 186 } 120 - else if (f.isDirectory()) { 121 - images(path.join(dir, f.name)).then(res).catch(reject); 187 + else { 188 + reject(new Error(`process ended with code ${code}`)); 122 189 } 123 - }))); 124 - } 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 + }); 125 199 } 126 200 127 201 function isAbortError(err: unknown): boolean { ··· 129 203 } 130 204 131 205 (async function () { 132 - 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)]); 133 212 if (process.argv.indexOf('--watch') >= 0) { 134 213 console.log('watching for changes...'); 135 214 (async () => { ··· 162 241 recursive: true // no Linux ☹️ 163 242 }); 164 243 for await (const _ of watcher) 165 - await images(); 244 + await images(webp, avif); 166 245 } catch (err) { 167 246 if (isAbortError(err)) 168 247 return;