diff --git a/build.ts b/build.ts index a3fe2c8..2756794 100644 --- a/build.ts +++ b/build.ts @@ -35,9 +35,11 @@ async function buildBlog(): Promise { const rawEntryList = await getEntryList(BLOG_FOLDER, `${MARKDOWN_BASE_URL_PLACEHOLDER}/blog/`); const entryList = applyBlogDefaults(rawEntryList); + // Copy + optimize images first: this rewrites header/html references to WebP in + // place, so the light list.json (written afterwards) references the WebP too. + await copyEntriesToDist(entryList, BLOG_FOLDER, blogDist); const blogListLight = makeLightBlogList(entryList); await writeJson(path.join(blogDist, LIST_FILE), blogListLight); - await copyEntriesToDist(entryList, BLOG_FOLDER, blogDist); console.log(`Blog: ${entryList.length} entries processed`); } @@ -52,9 +54,9 @@ async function buildMaterial(): Promise { await mkdirp(materialDist); const materialList = await getEntryList(MATERIAL_FOLDER, `${MARKDOWN_BASE_URL_PLACEHOLDER}/material/`); + await copyEntriesToDist(materialList, MATERIAL_FOLDER, materialDist); const materialListLight = makeLightList(materialList); await writeJson(path.join(materialDist, LIST_FILE), materialListLight); - await copyEntriesToDist(materialList, MATERIAL_FOLDER, materialDist); console.log(`Material: ${materialList.length} entries processed`); } diff --git a/package-lock.json b/package-lock.json index 2eaa043..e44c8df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "js-yaml": "^4.1.0", "marked": "^17.0.1", "marked-highlight": "^2.2.3", - "node-emoji": "^2.1.3" + "node-emoji": "^2.1.3", + "sharp": "^0.35.3" }, "devDependencies": { "@types/fs-extra": "^11.0.4", @@ -31,6 +32,16 @@ "@rollup/rollup-linux-x64-gnu": "^4.57.1" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -473,6 +484,554 @@ "node": ">=18" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "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.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1213,6 +1772,15 @@ } } }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/emojilib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", @@ -1774,10 +2342,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1786,6 +2353,55 @@ "node": ">=10" } }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1922,6 +2538,13 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/package.json b/package.json index c87617e..a064e11 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,14 @@ "description": "", "dependencies": { "fs-extra": "^11.2.0", + "github-slugger": "^2.0.0", "highlight.js": "^11.10.0", "image-size": "^2.0.2", "js-yaml": "^4.1.0", - "github-slugger": "^2.0.0", "marked": "^17.0.1", "marked-highlight": "^2.2.3", - "node-emoji": "^2.1.3" + "node-emoji": "^2.1.3", + "sharp": "^0.35.3" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.57.1", diff --git a/shared/base.utils.spec.ts b/shared/base.utils.spec.ts index b9ad2d9..27a7245 100644 --- a/shared/base.utils.spec.ts +++ b/shared/base.utils.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import sharp from 'sharp'; import { readMarkdownFile, readFolders, getImageDimensions, copyEntriesToDist, getEntryList, markdownToEntry } from './base.utils'; import { EntryBase } from './base.types'; import * as fs from 'fs/promises'; @@ -124,30 +125,39 @@ describe('base.utils', () => { await fs.rm(testDir, { recursive: true, force: true }); }); - it('should copy entry folder to dist and create entry.json', async () => { - // Create source entry + it('should copy entry, optimize images to webp, and rewrite references', async () => { + // Create source entry with a real PNG (sharp needs a valid image). const entrySlug = 'test-post'; await fs.mkdir(path.join(sourceDir, entrySlug)); await fs.writeFile(path.join(sourceDir, entrySlug, 'README.md'), '# Test'); - await fs.writeFile(path.join(sourceDir, entrySlug, 'image.png'), 'fake-image'); - - const entries = [{ slug: entrySlug, html: '

Test

', meta: { title: 'Test' } }]; + await sharp({ create: { width: 120, height: 80, channels: 3, background: { r: 10, g: 20, b: 30 } } }) + .png() + .toFile(path.join(sourceDir, entrySlug, 'image.png')); + + const entries = [{ + slug: entrySlug, + html: '

', + meta: { title: 'Test', header: { url: 'image.png', width: 120, height: 80 } }, + }]; await copyEntriesToDist(entries, sourceDir, distDir); - // Check entry.json was created + // entry.json created; html + header references rewritten to webp const entryJson = JSON.parse( await fs.readFile(path.join(distDir, entrySlug, 'entry.json'), 'utf8') ); expect(entryJson.slug).toBe(entrySlug); - expect(entryJson.html).toBe('

Test

'); - - // Check README.md was removed - await expect(fs.access(path.join(distDir, entrySlug, 'README.md'))) - .rejects.toThrow(); - - // Check other files were copied - const imageContent = await fs.readFile(path.join(distDir, entrySlug, 'image.png'), 'utf8'); - expect(imageContent).toBe('fake-image'); + expect(entryJson.html).toContain('image.webp'); + expect(entryJson.html).not.toContain('image.png'); + expect(entryJson.meta.header.url).toBe('image.webp'); + expect(entryJson.meta.header.width).toBe(120); + expect(entryJson.meta.header.height).toBe(80); + + // README.md removed + await expect(fs.access(path.join(distDir, entrySlug, 'README.md'))).rejects.toThrow(); + + // webp generated, original png removed + await expect(fs.access(path.join(distDir, entrySlug, 'image.webp'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(distDir, entrySlug, 'image.png'))).rejects.toThrow(); }); it('should handle multiple entries', async () => { diff --git a/shared/base.utils.ts b/shared/base.utils.ts index ba44b72..7255241 100644 --- a/shared/base.utils.ts +++ b/shared/base.utils.ts @@ -7,6 +7,7 @@ import { copy, remove, writeJson, mkdirp } from 'fs-extra'; import { JekyllMarkdownParser } from './jekyll-markdown-parser'; import { EntryBase, ImageDimensions } from './base.types'; import { registerAnchors, registerHtmlAnchors, registerLinks } from './link-validator'; +import { optimizeImagesInFolder, rewriteImageReferences } from './image-optimizer'; const README_FILE = 'README.md'; const ENTRY_FILE = 'entry.json'; @@ -34,11 +35,17 @@ export async function getImageDimensions(imagePath: string): Promise( entries: T[], sourceFolder: string, - distFolder: string + distFolder: string, ): Promise { // Process sequentially to fail fast on first error for (const entry of entries) { @@ -48,6 +55,10 @@ export async function copyEntriesToDist( await copy(path.join(sourceFolder, entry.slug), entryDistFolder); await remove(path.join(entryDistFolder, README_FILE)); + // jpg/png → resized WebP (in dist); rewrite header + html references to it. + const optimized = await optimizeImagesInFolder(entryDistFolder); + rewriteImageReferences(entry as { html?: unknown; meta?: unknown }, optimized); + const entryJsonPath = path.join(entryDistFolder, ENTRY_FILE); await writeJson(entryJsonPath, entry); console.log('Generated post file:', entryJsonPath); diff --git a/shared/image-optimizer.spec.ts b/shared/image-optimizer.spec.ts new file mode 100644 index 0000000..2bf0102 --- /dev/null +++ b/shared/image-optimizer.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import sharp from 'sharp'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { optimizeImagesInFolder, rewriteImageReferences, MAX_WIDTH } from './image-optimizer'; + +describe('image-optimizer', () => { + const dir = '/tmp/test-img-opt-' + Date.now(); + + beforeEach(async () => { + await fs.mkdir(dir, { recursive: true }); + }); + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + describe('optimizeImagesInFolder', () => { + it('converts jpg/png to webp, downscales wide images, removes originals', async () => { + await sharp({ create: { width: 2000, height: 800, channels: 3, background: '#123456' } }) + .jpeg() + .toFile(path.join(dir, 'big.jpg')); + await sharp({ create: { width: 100, height: 100, channels: 3, background: '#abcdef' } }) + .png() + .toFile(path.join(dir, 'small.png')); + // Image in a subfolder (recursion). + await fs.mkdir(path.join(dir, 'gallery'), { recursive: true }); + await sharp({ create: { width: 300, height: 200, channels: 3, background: '#00ff00' } }) + .jpeg() + .toFile(path.join(dir, 'gallery', 'shot.jpg')); + await fs.writeFile(path.join(dir, 'logo.svg'), ''); + + const map = await optimizeImagesInFolder(dir); + + // Wide image downscaled to MAX_WIDTH, small one not upscaled. + expect(map.get('big.jpg')?.webp).toBe('big.webp'); + expect(map.get('big.jpg')?.width).toBe(MAX_WIDTH); + expect(map.get('small.png')?.webp).toBe('small.webp'); + expect(map.get('small.png')?.width).toBe(100); + + // Subfolder image converted too (recursive), keyed by relative path. + expect(map.get(path.join('gallery', 'shot.jpg'))?.webp).toBe(path.join('gallery', 'shot.webp')); + await expect(fs.access(path.join(dir, 'gallery', 'shot.webp'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(dir, 'gallery', 'shot.jpg'))).rejects.toThrow(); + + // Originals removed, webp present. + await expect(fs.access(path.join(dir, 'big.jpg'))).rejects.toThrow(); + await expect(fs.access(path.join(dir, 'big.webp'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(dir, 'small.webp'))).resolves.toBeUndefined(); + + // SVG untouched and not in the map. + await expect(fs.access(path.join(dir, 'logo.svg'))).resolves.toBeUndefined(); + expect(map.has('logo.svg')).toBe(false); + }); + + it('returns an empty map when there is nothing to optimize', async () => { + await fs.writeFile(path.join(dir, 'notes.txt'), 'hello'); + const map = await optimizeImagesInFolder(dir); + expect(map.size).toBe(0); + }); + }); + + describe('rewriteImageReferences', () => { + it('rewrites header (url + dimensions) and html references, leaving others alone', () => { + const map = new Map([['header.jpg', { webp: 'header.webp', width: 800, height: 400 }]]); + const entry = { + html: ' und ', + meta: { header: { url: 'header.jpg', width: 2000, height: 1000 } }, + }; + + rewriteImageReferences(entry, map); + + expect(entry.meta.header.url).toBe('header.webp'); + expect(entry.meta.header.width).toBe(800); + expect(entry.meta.header.height).toBe(400); + expect(entry.html).toContain('header.webp'); + expect(entry.html).not.toContain('header.jpg'); + expect(entry.html).toContain('other.svg'); + }); + }); +}); diff --git a/shared/image-optimizer.ts b/shared/image-optimizer.ts new file mode 100644 index 0000000..76d7d34 --- /dev/null +++ b/shared/image-optimizer.ts @@ -0,0 +1,112 @@ +import * as path from 'path'; +import { readdir, unlink } from 'fs/promises'; +import sharp from 'sharp'; + +/** + * Image optimization for article assets. + * + * Raster images (jpg/jpeg/png) in an entry folder are converted to resized WebP. + * The original files are removed from the dist folder, so the CDN only serves the + * optimized variant. SVG, GIF and existing WebP files are left untouched. + * + * Every consuming website (angular.schule, angular-buch.com, agentic.schule, …) + * benefits automatically, because they all read the same generated JSON/images. + */ + +/** Max width in px; larger images are downscaled (never upscaled). */ +export const MAX_WIDTH = 1600; +/** WebP quality (0-100). */ +export const WEBP_QUALITY = 80; + +const RASTER = /\.(jpe?g|png)$/i; + +export interface OptimizedImage { + /** New relative path, e.g. "header.webp" or "gallery/shot.webp" */ + webp: string; + /** Dimensions of the generated WebP (after optional downscaling). */ + width: number; + height: number; +} + +/** Collect all file paths under `dir`, relative to `base` (recursive). */ +async function collectFiles(dir: string, base: string): Promise { + const dirents = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const dirent of dirents) { + const full = path.join(dir, dirent.name); + if (dirent.isDirectory()) { + files.push(...(await collectFiles(full, base))); + } else { + files.push(path.relative(base, full)); + } + } + return files; +} + +/** + * Convert all jpg/jpeg/png files in `folder` (recursively, incl. subfolders like + * `.../gallery/shot.jpg`) to resized WebP in place, deleting the originals. + * Returns a map: original relative path → optimized info. Empty when nothing to do. + */ +export async function optimizeImagesInFolder( + folder: string, +): Promise> { + const optimized = new Map(); + const files = await collectFiles(folder, folder); + + for (const rel of files) { + if (!RASTER.test(rel)) continue; + + const source = path.join(folder, rel); + const webpRel = rel.replace(RASTER, '.webp'); + const target = path.join(folder, webpRel); + + const info = await sharp(source) + .rotate() // respektiert EXIF-Orientierung + .resize({ width: MAX_WIDTH, withoutEnlargement: true }) + .webp({ quality: WEBP_QUALITY }) + .toFile(target); + + optimized.set(rel, { webp: webpRel, width: info.width, height: info.height }); + await unlink(source); + } + + return optimized; +} + +/** + * Rewrite image references in an entry after optimization (mutates in place): + * - `meta.header` (object with url/width/height) → WebP url + new dimensions + * - `html` → every reference to a converted file name is pointed at the WebP + */ +export function rewriteImageReferences( + entry: { html?: unknown; meta?: unknown }, + optimized: Map, +): void { + if (optimized.size === 0) return; + + // Header (used by both entry.json and the light list.json). + const meta = entry.meta as + | { header?: { url?: string; width?: number; height?: number } } + | undefined; + const header = meta?.header; + if (header && typeof header.url === 'string') { + const opt = optimized.get(header.url); + if (opt) { + header.url = opt.webp; + header.width = opt.width; + header.height = opt.height; + } + } + + // Content images inside the rendered HTML. Replace longer file names first so + // that a short name is never a partial match inside a longer one. + if (typeof entry.html === 'string') { + let html = entry.html; + const names = [...optimized.keys()].sort((a, b) => b.length - a.length); + for (const original of names) { + html = html.split(original).join(optimized.get(original)!.webp); + } + entry.html = html; + } +}