diff --git a/package-lock.json b/package-lock.json index 8b14d5e..8a0e342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "devDependencies": { "puppeteer": "^24.0.0", "purgecss": "^8.0.0", + "sharp": "^0.34.5", "terser": "^5.0.0" } }, @@ -36,6 +37,507 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -549,6 +1051,16 @@ "node": ">= 14" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1566079", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", @@ -1357,6 +1869,51 @@ "node": ">=10" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", diff --git a/package.json b/package.json index 5469087..96adb91 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "build": "/opt/homebrew/bin/hugo --minify && npx purgecss --config purgecss.config.js && npx terser public/js/bg-field.js -o public/js/bg-field.js -c -m", "build:check": "/opt/homebrew/bin/hugo --minify --renderToMemory", "preview": "/opt/homebrew/bin/hugo server --disableFastRender", + "assets": "node scripts/assets.mjs process", + "assets:check": "node scripts/assets.mjs check", + "assets:clean": "node scripts/assets.mjs clean", "clean": "rm -rf public/", "env": "/opt/homebrew/bin/hugo env", "test": "node tests/runner.js", @@ -17,6 +20,7 @@ "devDependencies": { "puppeteer": "^24.0.0", "purgecss": "^8.0.0", + "sharp": "^0.34.5", "terser": "^5.0.0" } } diff --git a/scripts/assets.mjs b/scripts/assets.mjs new file mode 100644 index 0000000..b7d4ded --- /dev/null +++ b/scripts/assets.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node + +/** + * Asset Import, Format & Compression Pipeline + * + * Processes images from source-assets/ into optimised, deployment-ready files + * in static/img/. Generates WebP variants alongside compressed originals, + * tracks every file in a manifest so unchanged assets are never reprocessed. + * + * Usage: + * npm run assets # process new / changed source images + * npm run assets:check # verify every source image is up-to-date + * npm run assets:clean # remove generated outputs (keep sources) + */ + +import { createHash } from "node:crypto"; +import { readFile, writeFile, readdir, mkdir, rm, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import sharp from "sharp"; + +// ── Paths ──────────────────────────────────────────────────────────────────── +const ROOT = resolve(import.meta.dirname, ".."); +const SOURCE_DIR = join(ROOT, "source-assets"); +const OUTPUT_DIR = join(ROOT, "static", "img"); +const MANIFEST_PATH = join(SOURCE_DIR, "manifest.json"); + +// ── Supported formats ──────────────────────────────────────────────────────── +const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".bmp", ".svg"]); +const RASTER_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".bmp"]); + +// ── Compression presets ────────────────────────────────────────────────────── +const WEBP_OPTS = { quality: 80, effort: 6 }; +const PNG_OPTS = { compressionLevel: 9, adaptiveFiltering: true }; +const JPEG_OPTS = { quality: 82, mozjpeg: true }; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** SHA-256 hex digest of a buffer. */ +function hash(buf) { + return createHash("sha256").update(buf).digest("hex"); +} + +/** Recursively collect image files under `dir`. */ +async function collectImages(dir) { + const entries = []; + if (!existsSync(dir)) return entries; + + for (const entry of await readdir(dir, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) continue; + const ext = extname(entry.name).toLowerCase(); + if (!IMAGE_EXTS.has(ext)) continue; + // parentPath is the canonical property (Node ≥20.12); path is the legacy alias + const full = join(entry.parentPath ?? entry.path, entry.name); + entries.push(full); + } + return entries; +} + +/** Load or initialise the manifest. */ +async function loadManifest() { + if (!existsSync(MANIFEST_PATH)) return {}; + try { + return JSON.parse(await readFile(MANIFEST_PATH, "utf8")); + } catch { + return {}; + } +} + +/** Persist manifest to disk. */ +async function saveManifest(manifest) { + const sorted = Object.fromEntries( + Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b)), + ); + await writeFile(MANIFEST_PATH, JSON.stringify(sorted, null, 2) + "\n"); +} + +/** Ensure a directory exists. */ +async function ensureDir(dir) { + await mkdir(dir, { recursive: true }); +} + +/** Format bytes in human-readable form. */ +function fmtSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + return `${(kb / 1024).toFixed(2)} MB`; +} + +// ── Core pipeline ──────────────────────────────────────────────────────────── + +async function processImage(srcPath, manifest) { + const relPath = relative(SOURCE_DIR, srcPath); + const srcBuf = await readFile(srcPath); + const srcHash = hash(srcBuf); + + // Skip if manifest shows the same hash (already processed) + if (manifest[relPath]?.sourceHash === srcHash) { + return { relPath, skipped: true }; + } + + const ext = extname(relPath).toLowerCase(); + const outRelDir = dirname(relPath); + const outDir = join(OUTPUT_DIR, outRelDir); + await ensureDir(outDir); + + const results = { relPath, skipped: false, outputs: [] }; + + // ── SVG: copy as-is (no raster processing) ──────────────────────────── + if (ext === ".svg") { + const outPath = join(OUTPUT_DIR, relPath); + await writeFile(outPath, srcBuf); + results.outputs.push({ + file: relPath, + format: "svg", + size: srcBuf.length, + }); + manifest[relPath] = { + sourceHash: srcHash, + processedAt: new Date().toISOString(), + outputs: results.outputs, + }; + return results; + } + + // ── Raster images ───────────────────────────────────────────────────── + if (!RASTER_EXTS.has(ext)) return results; + + const image = sharp(srcBuf); + const meta = await image.metadata(); + + // 1. Optimised original format + let optimised; + let outExt = ext; + if (ext === ".png") { + optimised = await image.clone().png(PNG_OPTS).toBuffer(); + } else if (ext === ".jpg" || ext === ".jpeg") { + optimised = await image.clone().jpeg(JPEG_OPTS).toBuffer(); + outExt = ext; // preserve .jpg vs .jpeg + } else if (ext === ".gif") { + // sharp has limited GIF write; keep original + optimised = srcBuf; + } else if (ext === ".tiff" || ext === ".tif") { + optimised = await image.clone().tiff({ compression: "lzw" }).toBuffer(); + } else if (ext === ".bmp") { + // Convert BMP → PNG for web + optimised = await image.clone().png(PNG_OPTS).toBuffer(); + outExt = ".png"; + } + + const origOutName = basename(relPath, ext) + outExt; + const origOutRel = join(outRelDir, origOutName); + await writeFile(join(OUTPUT_DIR, origOutRel), optimised); + results.outputs.push({ + file: origOutRel, + format: outExt.replace(".", ""), + width: meta.width, + height: meta.height, + size: optimised.length, + }); + + // 2. WebP variant + const webpBuf = await image.clone().webp(WEBP_OPTS).toBuffer(); + const webpName = basename(relPath, ext) + ".webp"; + const webpRel = join(outRelDir, webpName); + await writeFile(join(OUTPUT_DIR, webpRel), webpBuf); + results.outputs.push({ + file: webpRel, + format: "webp", + width: meta.width, + height: meta.height, + size: webpBuf.length, + }); + + // 3. Update manifest + manifest[relPath] = { + sourceHash: srcHash, + processedAt: new Date().toISOString(), + width: meta.width, + height: meta.height, + outputs: results.outputs, + }; + + return results; +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +async function cmdProcess() { + const files = await collectImages(SOURCE_DIR); + if (files.length === 0) { + console.log("ℹ No images found in source-assets/. Nothing to process."); + return; + } + + const manifest = await loadManifest(); + let processed = 0; + let skipped = 0; + let totalSaved = 0; + + for (const f of files) { + const res = await processImage(f, manifest); + if (res.skipped) { + skipped++; + continue; + } + processed++; + + const srcSize = (await stat(f)).size; + const outSize = res.outputs.reduce((s, o) => s + o.size, 0); + const saved = srcSize - (res.outputs[0]?.size ?? 0); + totalSaved += Math.max(0, saved); + + const outSummary = res.outputs + .map((o) => `${o.format} ${fmtSize(o.size)}`) + .join(", "); + console.log(` ✔ ${res.relPath} → ${outSummary}`); + } + + await saveManifest(manifest); + + console.log(""); + console.log(`Done. ${processed} processed, ${skipped} unchanged.`); + if (totalSaved > 0) { + console.log(`Saved ~${fmtSize(totalSaved)} on optimised originals.`); + } +} + +async function cmdCheck() { + const files = await collectImages(SOURCE_DIR); + const manifest = await loadManifest(); + let stale = 0; + + for (const f of files) { + const relPath = relative(SOURCE_DIR, f); + const srcBuf = await readFile(f); + const srcHash = hash(srcBuf); + if (manifest[relPath]?.sourceHash !== srcHash) { + console.log(` ✗ ${relPath} (needs processing)`); + stale++; + } + } + + if (stale === 0) { + console.log("All source assets are up-to-date."); + } else { + console.log(`\n${stale} asset(s) need processing. Run: npm run assets`); + process.exitCode = 1; + } +} + +async function cmdClean() { + const manifest = await loadManifest(); + let removed = 0; + + for (const [_relPath, entry] of Object.entries(manifest)) { + for (const output of entry.outputs ?? []) { + const outPath = join(OUTPUT_DIR, output.file); + if (existsSync(outPath)) { + await rm(outPath); + removed++; + } + } + } + + if (existsSync(MANIFEST_PATH)) await rm(MANIFEST_PATH); + console.log(`Cleaned ${removed} generated file(s) and manifest.`); +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +const cmd = process.argv[2] ?? "process"; + +switch (cmd) { + case "process": + await cmdProcess(); + break; + case "check": + await cmdCheck(); + break; + case "clean": + await cmdClean(); + break; + default: + console.error(`Unknown command: ${cmd}`); + console.error("Usage: node scripts/assets.mjs [process|check|clean]"); + process.exitCode = 1; +} diff --git a/source-assets/README.md b/source-assets/README.md new file mode 100644 index 0000000..ef1bbee --- /dev/null +++ b/source-assets/README.md @@ -0,0 +1,58 @@ +# Source Assets + +Drop **original, unoptimised** images here. +The asset pipeline (`npm run assets`) will compress, convert and deploy them +into `static/img/` automatically. + +## Directory layout + +Mirror the structure you want under `static/img/`: + +``` +source-assets/ +├── projects/ +│ ├── daily-digest/ +│ │ └── digest-hero.png ← raw screenshot / export +│ └── nurbits/ +│ └── nurbits-hero.jpg +├── achievements/ +│ └── coin-clicker-gold.png +├── me-illustration.png +└── manifest.json ← auto-generated, do not edit +``` + +After running `npm run assets`, the pipeline produces: + +``` +static/img/ +├── projects/daily-digest/ +│ ├── digest-hero.png ← optimised PNG +│ └── digest-hero.webp ← WebP variant +└── ... +``` + +## Commands + +| Command | Description | +| -------------------- | ---------------------------------------------- | +| `npm run assets` | Process new & changed images | +| `npm run assets:check` | Verify all sources are up-to-date (CI gate) | +| `npm run assets:clean` | Remove generated outputs & manifest | + +## Supported formats + +PNG, JPG/JPEG, GIF, TIFF, BMP, SVG. +Raster images get a compressed original **plus** a WebP version. +SVGs are copied as-is (vector, no raster conversion). + +## How it works + +1. Each source file is hashed (SHA-256). +2. The hash is compared against `manifest.json`. +3. Changed files are compressed with [sharp](https://sharp.pixelplumbing.com/): + - **PNG** → `compressionLevel: 9` + adaptive filtering + - **JPG** → MozJPEG `quality: 82` + - **WebP** → `quality: 80, effort: 6` +4. Outputs land in `static/img/` and the manifest is updated. + +Unchanged images are skipped — the pipeline is idempotent.