diff --git a/.gitignore b/.gitignore index 81d295b..b5b5571 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ docs/ # vercel cli .vercel/ +# OG 生圖腳本的字型與 emoji 快取(CJK 全字集十幾 MB,不進版控) +scripts/.cache/ + # test coverage coverage/ diff --git a/astro.config.mjs b/astro.config.mjs index 7653b0a..1010904 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -63,6 +63,9 @@ export default defineConfig({ }, injectManifest: { globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'], + // 分享卡只有 FB/X/LINE 的 crawler 會抓,使用者永遠不會開。 + // 不排除的話這 66 張(約 2.3MB)會被塞進 service worker 預快取。 + globIgnores: ['og/**'], }, }), ], diff --git a/package-lock.json b/package-lock.json index 62b5788..792a140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "devDependencies": { "@astrojs/sitemap": "^3.7.0", "@playwright/test": "^1.61.1", + "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.1.8", "@testing-library/react": "^16.3.2", "@types/qrcode": "^1.5.6", @@ -31,6 +32,7 @@ "@vite-pwa/astro": "^1.2.0", "@vitejs/plugin-react": "^6.0.2", "jsdom": "^29.1.1", + "satori": "^0.26.0", "tailwindcss": "^4.3.0", "vitest": "^4.1.8", "workbox-core": "^7.4.1", @@ -3366,6 +3368,234 @@ "pnpm": "^10.0.0" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -4174,6 +4404,23 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6637,6 +6884,16 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -6937,6 +7194,40 @@ "node": ">=8" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-gradient-parser": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", + "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -6953,6 +7244,18 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -7424,6 +7727,16 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.22.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", @@ -7642,6 +7955,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -7814,6 +8134,13 @@ } } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "dev": true, + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -8496,6 +8823,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -9614,6 +9954,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -10996,6 +11357,24 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/parse-latin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", @@ -11226,6 +11605,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -11934,6 +12320,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/satori": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.26.0.tgz", + "integrity": "sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.17", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-layout": "^3.2.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -12419,6 +12828,13 @@ "node": ">=8" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "dev": true, + "license": "MIT" + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -13118,6 +13534,17 @@ "node": ">=4" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -14233,6 +14660,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index c7fcc51..3dd6901 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "typecheck:api": "tsc -p api/tsconfig.json", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "og": "node --experimental-strip-types scripts/gen-og.mjs" }, "dependencies": { "@astrojs/react": "^5.0.7", @@ -33,6 +34,7 @@ "devDependencies": { "@astrojs/sitemap": "^3.7.0", "@playwright/test": "^1.61.1", + "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.1.8", "@testing-library/react": "^16.3.2", "@types/qrcode": "^1.5.6", @@ -40,6 +42,7 @@ "@vite-pwa/astro": "^1.2.0", "@vitejs/plugin-react": "^6.0.2", "jsdom": "^29.1.1", + "satori": "^0.26.0", "tailwindcss": "^4.3.0", "vitest": "^4.1.8", "workbox-core": "^7.4.1", diff --git a/public/og/en/2048.png b/public/og/en/2048.png new file mode 100644 index 0000000..af83ee2 Binary files /dev/null and b/public/og/en/2048.png differ diff --git a/public/og/en/bingo.png b/public/og/en/bingo.png new file mode 100644 index 0000000..cfa6302 Binary files /dev/null and b/public/og/en/bingo.png differ diff --git a/public/og/en/calculator.png b/public/og/en/calculator.png new file mode 100644 index 0000000..3cc3790 Binary files /dev/null and b/public/og/en/calculator.png differ diff --git a/public/og/en/color-converter.png b/public/og/en/color-converter.png new file mode 100644 index 0000000..d4f0822 Binary files /dev/null and b/public/og/en/color-converter.png differ diff --git a/public/og/en/color-extractor.png b/public/og/en/color-extractor.png new file mode 100644 index 0000000..39c3ff0 Binary files /dev/null and b/public/og/en/color-extractor.png differ diff --git a/public/og/en/converter.png b/public/og/en/converter.png new file mode 100644 index 0000000..d5bb8b4 Binary files /dev/null and b/public/og/en/converter.png differ diff --git a/public/og/en/date-calc.png b/public/og/en/date-calc.png new file mode 100644 index 0000000..18b06ac Binary files /dev/null and b/public/og/en/date-calc.png differ diff --git a/public/og/en/dice.png b/public/og/en/dice.png new file mode 100644 index 0000000..afaf4e1 Binary files /dev/null and b/public/og/en/dice.png differ diff --git a/public/og/en/encoder.png b/public/og/en/encoder.png new file mode 100644 index 0000000..9e3fe1e Binary files /dev/null and b/public/og/en/encoder.png differ diff --git a/public/og/en/everyday-calc.png b/public/og/en/everyday-calc.png new file mode 100644 index 0000000..c9a169b Binary files /dev/null and b/public/og/en/everyday-calc.png differ diff --git a/public/og/en/ftp-zones.png b/public/og/en/ftp-zones.png new file mode 100644 index 0000000..3be58e8 Binary files /dev/null and b/public/og/en/ftp-zones.png differ diff --git a/public/og/en/gear-calculator.png b/public/og/en/gear-calculator.png new file mode 100644 index 0000000..875bfeb Binary files /dev/null and b/public/og/en/gear-calculator.png differ diff --git a/public/og/en/hash-id.png b/public/og/en/hash-id.png new file mode 100644 index 0000000..578e38d Binary files /dev/null and b/public/og/en/hash-id.png differ diff --git a/public/og/en/home.png b/public/og/en/home.png new file mode 100644 index 0000000..b359b2e Binary files /dev/null and b/public/og/en/home.png differ diff --git a/public/og/en/image-studio.png b/public/og/en/image-studio.png new file mode 100644 index 0000000..cf00cb7 Binary files /dev/null and b/public/og/en/image-studio.png differ diff --git a/public/og/en/json-formatter.png b/public/og/en/json-formatter.png new file mode 100644 index 0000000..3e6e208 Binary files /dev/null and b/public/og/en/json-formatter.png differ diff --git a/public/og/en/markdown-studio.png b/public/og/en/markdown-studio.png new file mode 100644 index 0000000..97a8dad Binary files /dev/null and b/public/og/en/markdown-studio.png differ diff --git a/public/og/en/password.png b/public/og/en/password.png new file mode 100644 index 0000000..d1cbd73 Binary files /dev/null and b/public/og/en/password.png differ diff --git a/public/og/en/qrcode.png b/public/og/en/qrcode.png new file mode 100644 index 0000000..442ad5a Binary files /dev/null and b/public/og/en/qrcode.png differ diff --git a/public/og/en/raffle.png b/public/og/en/raffle.png new file mode 100644 index 0000000..3586551 Binary files /dev/null and b/public/og/en/raffle.png differ diff --git a/public/og/en/ride-fuel.png b/public/og/en/ride-fuel.png new file mode 100644 index 0000000..0e1c74c Binary files /dev/null and b/public/og/en/ride-fuel.png differ diff --git a/public/og/en/snake.png b/public/og/en/snake.png new file mode 100644 index 0000000..9aa9472 Binary files /dev/null and b/public/og/en/snake.png differ diff --git a/public/og/en/stage-profile.png b/public/og/en/stage-profile.png new file mode 100644 index 0000000..07edad8 Binary files /dev/null and b/public/og/en/stage-profile.png differ diff --git a/public/og/en/text-calculator.png b/public/og/en/text-calculator.png new file mode 100644 index 0000000..d50333d Binary files /dev/null and b/public/og/en/text-calculator.png differ diff --git a/public/og/en/text-diff.png b/public/og/en/text-diff.png new file mode 100644 index 0000000..4a2a905 Binary files /dev/null and b/public/og/en/text-diff.png differ diff --git a/public/og/en/tic-tac-toe.png b/public/og/en/tic-tac-toe.png new file mode 100644 index 0000000..0add4ed Binary files /dev/null and b/public/og/en/tic-tac-toe.png differ diff --git a/public/og/en/timer.png b/public/og/en/timer.png new file mode 100644 index 0000000..3d98f75 Binary files /dev/null and b/public/og/en/timer.png differ diff --git a/public/og/en/tire-pressure.png b/public/og/en/tire-pressure.png new file mode 100644 index 0000000..227b746 Binary files /dev/null and b/public/og/en/tire-pressure.png differ diff --git a/public/og/en/tw-id.png b/public/og/en/tw-id.png new file mode 100644 index 0000000..3e9c91f Binary files /dev/null and b/public/og/en/tw-id.png differ diff --git a/public/og/en/url-audit.png b/public/og/en/url-audit.png new file mode 100644 index 0000000..f318784 Binary files /dev/null and b/public/og/en/url-audit.png differ diff --git a/public/og/en/wheel.png b/public/og/en/wheel.png new file mode 100644 index 0000000..6737f7f Binary files /dev/null and b/public/og/en/wheel.png differ diff --git a/public/og/en/word-count.png b/public/og/en/word-count.png new file mode 100644 index 0000000..ef1771c Binary files /dev/null and b/public/og/en/word-count.png differ diff --git a/public/og/en/world-clock.png b/public/og/en/world-clock.png new file mode 100644 index 0000000..4983774 Binary files /dev/null and b/public/og/en/world-clock.png differ diff --git a/public/og/zh/2048.png b/public/og/zh/2048.png new file mode 100644 index 0000000..b22053e Binary files /dev/null and b/public/og/zh/2048.png differ diff --git a/public/og/zh/bingo.png b/public/og/zh/bingo.png new file mode 100644 index 0000000..29f4688 Binary files /dev/null and b/public/og/zh/bingo.png differ diff --git a/public/og/zh/calculator.png b/public/og/zh/calculator.png new file mode 100644 index 0000000..6ab1452 Binary files /dev/null and b/public/og/zh/calculator.png differ diff --git a/public/og/zh/color-converter.png b/public/og/zh/color-converter.png new file mode 100644 index 0000000..90eae4a Binary files /dev/null and b/public/og/zh/color-converter.png differ diff --git a/public/og/zh/color-extractor.png b/public/og/zh/color-extractor.png new file mode 100644 index 0000000..3f4554b Binary files /dev/null and b/public/og/zh/color-extractor.png differ diff --git a/public/og/zh/converter.png b/public/og/zh/converter.png new file mode 100644 index 0000000..d21a7fc Binary files /dev/null and b/public/og/zh/converter.png differ diff --git a/public/og/zh/date-calc.png b/public/og/zh/date-calc.png new file mode 100644 index 0000000..f47537c Binary files /dev/null and b/public/og/zh/date-calc.png differ diff --git a/public/og/zh/dice.png b/public/og/zh/dice.png new file mode 100644 index 0000000..b42f2fc Binary files /dev/null and b/public/og/zh/dice.png differ diff --git a/public/og/zh/encoder.png b/public/og/zh/encoder.png new file mode 100644 index 0000000..8fa5296 Binary files /dev/null and b/public/og/zh/encoder.png differ diff --git a/public/og/zh/everyday-calc.png b/public/og/zh/everyday-calc.png new file mode 100644 index 0000000..cb712fa Binary files /dev/null and b/public/og/zh/everyday-calc.png differ diff --git a/public/og/zh/ftp-zones.png b/public/og/zh/ftp-zones.png new file mode 100644 index 0000000..3f05a0c Binary files /dev/null and b/public/og/zh/ftp-zones.png differ diff --git a/public/og/zh/gear-calculator.png b/public/og/zh/gear-calculator.png new file mode 100644 index 0000000..51237b6 Binary files /dev/null and b/public/og/zh/gear-calculator.png differ diff --git a/public/og/zh/hash-id.png b/public/og/zh/hash-id.png new file mode 100644 index 0000000..c21cd9e Binary files /dev/null and b/public/og/zh/hash-id.png differ diff --git a/public/og/zh/home.png b/public/og/zh/home.png new file mode 100644 index 0000000..adf1790 Binary files /dev/null and b/public/og/zh/home.png differ diff --git a/public/og/zh/image-studio.png b/public/og/zh/image-studio.png new file mode 100644 index 0000000..78002f1 Binary files /dev/null and b/public/og/zh/image-studio.png differ diff --git a/public/og/zh/json-formatter.png b/public/og/zh/json-formatter.png new file mode 100644 index 0000000..ba35d3c Binary files /dev/null and b/public/og/zh/json-formatter.png differ diff --git a/public/og/zh/markdown-studio.png b/public/og/zh/markdown-studio.png new file mode 100644 index 0000000..2c0e962 Binary files /dev/null and b/public/og/zh/markdown-studio.png differ diff --git a/public/og/zh/password.png b/public/og/zh/password.png new file mode 100644 index 0000000..8cc3cf1 Binary files /dev/null and b/public/og/zh/password.png differ diff --git a/public/og/zh/qrcode.png b/public/og/zh/qrcode.png new file mode 100644 index 0000000..fcd2170 Binary files /dev/null and b/public/og/zh/qrcode.png differ diff --git a/public/og/zh/raffle.png b/public/og/zh/raffle.png new file mode 100644 index 0000000..2726e24 Binary files /dev/null and b/public/og/zh/raffle.png differ diff --git a/public/og/zh/ride-fuel.png b/public/og/zh/ride-fuel.png new file mode 100644 index 0000000..1bf6ed2 Binary files /dev/null and b/public/og/zh/ride-fuel.png differ diff --git a/public/og/zh/snake.png b/public/og/zh/snake.png new file mode 100644 index 0000000..1a6a7c0 Binary files /dev/null and b/public/og/zh/snake.png differ diff --git a/public/og/zh/stage-profile.png b/public/og/zh/stage-profile.png new file mode 100644 index 0000000..7be45ba Binary files /dev/null and b/public/og/zh/stage-profile.png differ diff --git a/public/og/zh/text-calculator.png b/public/og/zh/text-calculator.png new file mode 100644 index 0000000..3929ef2 Binary files /dev/null and b/public/og/zh/text-calculator.png differ diff --git a/public/og/zh/text-diff.png b/public/og/zh/text-diff.png new file mode 100644 index 0000000..2f9f40b Binary files /dev/null and b/public/og/zh/text-diff.png differ diff --git a/public/og/zh/tic-tac-toe.png b/public/og/zh/tic-tac-toe.png new file mode 100644 index 0000000..245447d Binary files /dev/null and b/public/og/zh/tic-tac-toe.png differ diff --git a/public/og/zh/timer.png b/public/og/zh/timer.png new file mode 100644 index 0000000..a68b370 Binary files /dev/null and b/public/og/zh/timer.png differ diff --git a/public/og/zh/tire-pressure.png b/public/og/zh/tire-pressure.png new file mode 100644 index 0000000..8ce4d39 Binary files /dev/null and b/public/og/zh/tire-pressure.png differ diff --git a/public/og/zh/tw-id.png b/public/og/zh/tw-id.png new file mode 100644 index 0000000..dc82316 Binary files /dev/null and b/public/og/zh/tw-id.png differ diff --git a/public/og/zh/url-audit.png b/public/og/zh/url-audit.png new file mode 100644 index 0000000..9f693f1 Binary files /dev/null and b/public/og/zh/url-audit.png differ diff --git a/public/og/zh/wheel.png b/public/og/zh/wheel.png new file mode 100644 index 0000000..fe09a45 Binary files /dev/null and b/public/og/zh/wheel.png differ diff --git a/public/og/zh/word-count.png b/public/og/zh/word-count.png new file mode 100644 index 0000000..339f836 Binary files /dev/null and b/public/og/zh/word-count.png differ diff --git a/public/og/zh/world-clock.png b/public/og/zh/world-clock.png new file mode 100644 index 0000000..4e400a5 Binary files /dev/null and b/public/og/zh/world-clock.png differ diff --git a/scripts/gen-og.mjs b/scripts/gen-og.mjs new file mode 100644 index 0000000..7d052b4 --- /dev/null +++ b/scripts/gen-og.mjs @@ -0,0 +1,200 @@ +// 產生分享卡(Open Graph)圖:每個工具、每個語系一張,加上首頁兩張。 +// +// 手動執行:`npm run og`。刻意不掛在 build 上 —— 卡片只在新增或改名工具時才變, +// 而 build 時抓字型/生圖多一層會在部署時才爆。產物 commit 進 public/og/, +// src/lib/__tests__/og.test.ts 守住「每個工具都有卡」。 +// +// 字型(CJK 全字集,十幾 MB)與 emoji 快取放在 scripts/.cache/,已 gitignore。 +// satori 只吃 TTF/OTF,不吃 woff2;Google Fonts 要用舊版 UA 才會回 TTF。 + +import satori from 'satori'; +import { Resvg } from '@resvg/resvg-js'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { tools } from '../src/data/tools.ts'; +import { CATEGORY_EN } from '../src/lib/i18n.ts'; +import { CATEGORY_COLORS, CARD_BG } from '../src/data/categories.ts'; +import { ogImagePath } from '../src/lib/og.ts'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CACHE = resolve(ROOT, 'scripts/.cache'); +const PUBLIC = resolve(ROOT, 'public'); + +const W = 1200; +const H = 630; +const INK = '#1A1A18'; +const MUTED = '#6B6B66'; +const EDGE = '#E8E4D9'; +const BRAND = '#D97757'; +const SLOT = 300; + +const TWEMOJI = '15.1.0'; // 釘住版本,避免上游改圖讓輸出漂移 + +const FONTS = [ + { name: 'Newsreader', family: 'Newsreader', weight: 600 }, + { name: 'NotoSerifTC', family: 'Noto Serif TC', weight: 600 }, + { name: 'Inter', family: 'Inter', weight: 500 }, + { name: 'NotoSansTC', family: 'Noto Sans TC', weight: 500 }, +]; + +const DISPLAY = 'Newsreader, Noto Serif TC'; +const UTILITY = 'Inter, Noto Sans TC'; + +async function cached(key, fetchIt) { + const file = resolve(CACHE, key); + if (existsSync(file)) return readFile(file); + await mkdir(dirname(file), { recursive: true }); + const data = await fetchIt(); + await writeFile(file, data); + return data; +} + +/** Google Fonts 的 CSS API 對舊 UA 才回 TTF;先取 CSS 再抓裡面的直連。 */ +async function loadFont({ name, family, weight }) { + return cached(`fonts/${name}-${weight}.ttf`, async () => { + const cssUrl = `https://fonts.googleapis.com/css2?family=${family.replace(/ /g, '+')}:wght@${weight}`; + const css = await fetch(cssUrl, { headers: { 'User-Agent': 'Mozilla/4.0' } }).then((r) => r.text()); + const ttf = css.match(/https:\/\/[^)]+\.(?:ttf|otf)/)?.[0]; + if (!ttf) throw new Error(`no TTF for ${family} ${weight} — Google Fonts may have changed its UA sniffing`); + const buf = Buffer.from(await fetch(ttf).then((r) => r.arrayBuffer())); + // TTF/OTF magic:00 01 00 00(TrueType)或 "OTTO"(CFF) + const magic = buf.subarray(0, 4).toString('hex'); + if (magic !== '00010000' && buf.subarray(0, 4).toString() !== 'OTTO') { + throw new Error(`${family} ${weight} is not a TTF/OTF (magic ${magic}) — satori cannot read woff2`); + } + console.log(` font ${family} ${weight} ${(buf.length / 1024 / 1024).toFixed(1)} MB`); + return buf; + }); +} + +/** twemoji 檔名是 codepoint,要去掉變體選擇符(U+FE0F)與 ZWJ。 */ +const codepoints = (emoji) => + [...emoji] + .map((c) => c.codePointAt(0).toString(16)) + .filter((c) => c !== 'fe0f' && c !== '200d') + .join('-'); + +async function emojiDataUri(emoji) { + const cp = codepoints(emoji); + const svg = await cached(`emoji/${cp}.svg`, async () => { + const url = `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI}/assets/svg/${cp}.svg`; + const res = await fetch(url); + if (!res.ok) throw new Error(`no twemoji for ${emoji} (U+${cp}): ${res.status} ${url}`); + return Buffer.from(await res.text()); + }); + return `data:image/svg+xml;base64,${svg.toString('base64')}`; +} + +const h = (type, props = {}, ...children) => ({ type, props: { ...props, children: children.flat().filter(Boolean) } }); + +/** 標記直接畫,不嵌 public/icon.svg —— 那支 SVG 的 T 是 ,resvg 沒有 Georgia,字會消失。 */ +const logoMark = (size) => + h( + 'div', + { style: { width: size, height: size, borderRadius: size * 0.24, background: BRAND, display: 'flex', alignItems: 'center', justifyContent: 'center' } }, + h('div', { style: { display: 'flex', fontFamily: 'Newsreader', fontSize: size * 0.72, fontWeight: 600, color: CARD_BG, marginTop: -size * 0.06 } }, 'T') + ); + +/** 卡片上的描述取到第一個句號為止 —— 首頁格子需要完整句子,分享卡不需要。 */ +const firstSentence = (text) => { + const end = text.search(/[。.]/); + return end === -1 ? text : text.slice(0, end + 1); +}; + +function card({ spine, eyebrow, eyebrowColor, title, desc, hero, locale }) { + return h( + 'div', + { style: { width: W, height: H, display: 'flex', background: CARD_BG } }, + // 書脊:分類色,像工具箱抽屜裡的檔案側標 + h('div', { style: { width: 20, height: H, background: spine, display: 'flex' } }), + h( + 'div', + { style: { flex: 1, display: 'flex', flexDirection: 'column', padding: '70px 72px 56px 76px' } }, + h( + 'div', + { style: { flex: 1, display: 'flex', alignItems: 'center' } }, + h( + 'div', + { style: { flex: 1, display: 'flex', flexDirection: 'column', maxWidth: 660 } }, + h('div', { style: { display: 'flex', fontFamily: UTILITY, fontSize: 26, fontWeight: 500, letterSpacing: locale === 'en' ? 4 : 6, color: eyebrowColor, textTransform: locale === 'en' ? 'uppercase' : 'none' } }, eyebrow), + h('div', { style: { display: 'flex', marginTop: 24, fontFamily: DISPLAY, fontSize: locale === 'en' ? 74 : 86, fontWeight: 600, color: INK, lineHeight: 1.14, letterSpacing: locale === 'en' ? -1 : 1 } }, title), + h('div', { style: { display: 'flex', marginTop: 26, fontFamily: UTILITY, fontSize: 30, fontWeight: 500, color: MUTED, lineHeight: 1.5 } }, desc) + ), + // emoji 自帶的配色不歸我們管;淡染的圓角凹槽把它收成「插在插槽裡的工具」 + h( + 'div', + { style: { width: SLOT, height: SLOT, marginLeft: 40, flexShrink: 0, borderRadius: SLOT * 0.24, background: `${spine}1F`, display: 'flex', alignItems: 'center', justifyContent: 'center' } }, + hero + ) + ), + h('div', { style: { display: 'flex', height: 1, background: EDGE, marginBottom: 30 } }), + h( + 'div', + { style: { display: 'flex', alignItems: 'center' } }, + logoMark(38), + h('div', { style: { display: 'flex', marginLeft: 16, fontFamily: DISPLAY, fontSize: 30, fontWeight: 600, color: INK } }, 'Tiglet'), + h('div', { style: { flex: 1, display: 'flex' } }), + h('div', { style: { display: 'flex', fontFamily: UTILITY, fontSize: 24, fontWeight: 500, color: MUTED } }, 'tiglet.vercel.app') + ) + ) + ); +} + +async function render(element, fonts, outPath) { + const svg = await satori(element, { width: W, height: H, fonts }); + const png = new Resvg(svg, { fitTo: { mode: 'width', value: W } }).render().asPng(); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, png); + return png.length; +} + +const HOME = { + zh: { title: 'Tiglet', desc: '一組乾淨、免登入的瀏覽器小工具。', eyebrow: '小工具站' }, + en: { title: 'Tiglet', desc: 'A clean, no-login collection of small browser tools.', eyebrow: 'Browser tools' }, +}; + +console.log('loading fonts (cached in scripts/.cache)…'); +const fonts = await Promise.all( + FONTS.map(async (f) => ({ name: f.family, data: await loadFont(f), weight: f.weight, style: 'normal' })) +); + +let count = 0; +let bytes = 0; + +for (const locale of ['zh', 'en']) { + for (const tool of tools) { + const { spine, eyebrow } = CATEGORY_COLORS[tool.category]; + const element = card({ + locale, + spine, + eyebrowColor: eyebrow, + eyebrow: locale === 'en' ? CATEGORY_EN[tool.category] : tool.category, + title: locale === 'en' ? tool.titleEn : tool.title, + desc: firstSentence(locale === 'en' ? tool.descriptionEn : tool.description), + hero: h('img', { src: await emojiDataUri(tool.icon), width: 176, height: 176 }), + }); + bytes += await render(element, fonts, resolve(PUBLIC, `.${ogImagePath(tool.id, locale)}`)); + count++; + } + + const home = HOME[locale]; + bytes += await render( + card({ + locale, + spine: BRAND, + eyebrowColor: CATEGORY_COLORS['實用工具'].eyebrow, + eyebrow: home.eyebrow, + title: home.title, + desc: home.desc, + hero: logoMark(176), + }), + fonts, + resolve(PUBLIC, `.${ogImagePath(undefined, locale)}`) + ); + count++; +} + +console.log(`generated ${count} cards, ${(bytes / 1024 / 1024).toFixed(2)} MB total`); diff --git a/src/data/categories.ts b/src/data/categories.ts new file mode 100644 index 0000000..2fc7179 --- /dev/null +++ b/src/data/categories.ts @@ -0,0 +1,23 @@ +import type { ToolCategory } from './tools'; + +/** 分享卡的底色,與 global.css 的 --color-bg(亮色主題)一致 */ +export const CARD_BG = '#FAF9F5'; + +/** + * 每個分類一組色,用在分享卡上。 + * + * `spine` 是左緣的大色塊,直接用品牌調色(陶土橘的同族:暖、去飽和、亮度相近; + * 實用工具沿用房子色本身)。`eyebrow` 是小字,同一個色壓在 cream 上讀不清楚 + * (陶土橘只有 2.96:1),所以改用壓暗到 4.5:1 的版本。 + * + * eyebrow 是字面值而非在此計算,讓 src/data 維持純資料、可被任何 runtime 直接 import; + * src/lib/__tests__/og.test.ts 會用 darkenUntilReadable() 反推驗證,值漂掉就會紅。 + */ +export const CATEGORY_COLORS: Record = { + 實用工具: { spine: '#D97757', eyebrow: '#a95d44' }, // 品牌陶土橘 + 計算: { spine: '#5B7C99', eyebrow: '#567590' }, // 灰藍 + 文字: { spine: '#6F8B5C', eyebrow: '#627a51' }, // 鼠尾草綠 + 隨機決定: { spine: '#A8853A', eyebrow: '#8a6d30' }, // 黃銅 + 遊戲: { spine: '#8A6A9B', eyebrow: '#846695' }, // 灰紫 + 單車: { spine: '#2F6F62', eyebrow: '#2f6f62' }, // 深松綠,本來就過門檻 +}; diff --git a/src/lib/__tests__/contrast.test.ts b/src/lib/__tests__/contrast.test.ts new file mode 100644 index 0000000..e35de35 --- /dev/null +++ b/src/lib/__tests__/contrast.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { contrastRatio, darkenUntilReadable, relativeLuminance } from '../contrast'; + +describe('relativeLuminance', () => { + it('anchors at the extremes', () => { + expect(relativeLuminance('#000000')).toBe(0); + expect(relativeLuminance('#FFFFFF')).toBe(1); + }); + + it('is case-insensitive', () => { + expect(relativeLuminance('#d97757')).toBeCloseTo(relativeLuminance('#D97757'), 12); + }); +}); + +describe('contrastRatio', () => { + it('is 21:1 for black on white', () => { + expect(contrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 2); + }); + + it('is 1:1 for a colour against itself', () => { + expect(contrastRatio('#D97757', '#D97757')).toBeCloseTo(1, 12); + }); + + it('does not depend on argument order', () => { + expect(contrastRatio('#2F6F62', '#FAF9F5')).toBeCloseTo(contrastRatio('#FAF9F5', '#2F6F62'), 12); + }); + + it('reports the terracotta brand colour as too weak on cream', () => { + // 這正是設計稿抓到的問題:品牌橘直接當小字用讀不清楚 + expect(contrastRatio('#D97757', '#FAF9F5')).toBeLessThan(4.5); + }); +}); + +describe('darkenUntilReadable', () => { + const CREAM = '#FAF9F5'; + + it('darkens a weak colour until it clears the target', () => { + const out = darkenUntilReadable('#D97757', CREAM, 4.5); + expect(contrastRatio(out, CREAM)).toBeGreaterThanOrEqual(4.5); + }); + + it('leaves a colour that already clears the target untouched', () => { + // 深松綠在 cream 上本來就有 5.58:1 + expect(darkenUntilReadable('#2F6F62', CREAM, 4.5)).toBe('#2f6f62'); + }); + + it('returns a valid six-digit hex', () => { + expect(darkenUntilReadable('#A8853A', CREAM, 4.5)).toMatch(/^#[0-9a-f]{6}$/); + }); + + it('is deterministic', () => { + expect(darkenUntilReadable('#8A6A9B', CREAM, 4.5)).toBe(darkenUntilReadable('#8A6A9B', CREAM, 4.5)); + }); + + it('never returns a lighter colour than it was given', () => { + expect(relativeLuminance(darkenUntilReadable('#D97757', CREAM, 4.5))).toBeLessThanOrEqual(relativeLuminance('#D97757')); + }); +}); diff --git a/src/lib/__tests__/og.test.ts b/src/lib/__tests__/og.test.ts new file mode 100644 index 0000000..24b1956 --- /dev/null +++ b/src/lib/__tests__/og.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ogImagePath } from '../og'; +import { tools } from '../../data/tools'; +import { CARD_BG, CATEGORY_COLORS } from '../../data/categories'; +import { contrastRatio, darkenUntilReadable } from '../contrast'; +import type { Locale } from '../i18n'; + +const LOCALES: Locale[] = ['zh', 'en']; + +describe('ogImagePath', () => { + it('namespaces a tool card by locale', () => { + expect(ogImagePath('url-audit', 'zh')).toBe('/og/zh/url-audit.png'); + expect(ogImagePath('url-audit', 'en')).toBe('/og/en/url-audit.png'); + }); + + it('falls back to the home card when there is no tool', () => { + expect(ogImagePath(undefined, 'zh')).toBe('/og/zh/home.png'); + expect(ogImagePath(undefined, 'en')).toBe('/og/en/home.png'); + }); +}); + +describe('category colours', () => { + it('covers every category a tool actually uses', () => { + for (const tool of tools) { + expect(CATEGORY_COLORS[tool.category], tool.category).toBeDefined(); + } + }); + + it('keeps every eyebrow readable on the cream card background', () => { + for (const [cat, { eyebrow }] of Object.entries(CATEGORY_COLORS)) { + expect(contrastRatio(eyebrow, CARD_BG), `${cat} ${eyebrow}`).toBeGreaterThanOrEqual(4.5); + } + }); + + // eyebrow 是字面值,這裡把它鎖回 darkenUntilReadable() 的輸出,避免有人手改後偷偷偏離 + it('derives every eyebrow from its spine colour', () => { + for (const [cat, { spine, eyebrow }] of Object.entries(CATEGORY_COLORS)) { + expect(darkenUntilReadable(spine, CARD_BG), cat).toBe(eyebrow); + } + }); +}); + +// 生圖是手動步驟(npm run og)。新增工具卻忘了重生圖時,這裡要紅, +// 而不是等到分享出去才發現卡片是 404。 +describe('generated OG images', () => { + const publicDir = resolve(import.meta.dirname, '../../../public'); + + it('has a card for every tool in every locale', () => { + const missing: string[] = []; + for (const tool of tools) { + for (const locale of LOCALES) { + const rel = ogImagePath(tool.id, locale); + if (!existsSync(resolve(publicDir, `.${rel}`))) missing.push(rel); + } + } + expect(missing, `run \`npm run og\` to generate: ${missing.join(', ')}`).toEqual([]); + }); + + it('has a home card in every locale', () => { + for (const locale of LOCALES) { + expect(existsSync(resolve(publicDir, `.${ogImagePath(undefined, locale)}`)), locale).toBe(true); + } + }); +}); diff --git a/src/lib/contrast.ts b/src/lib/contrast.ts new file mode 100644 index 0000000..3347384 --- /dev/null +++ b/src/lib/contrast.ts @@ -0,0 +1,39 @@ +// WCAG 相對亮度與對比度。用來確保分類色當小字時在卡片底色上讀得清楚, +// 不靠目測。純函式,只吃 #RRGGBB。 + +function channels(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]; +} + +function linearize(channel: number): number { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +export function relativeLuminance(hex: string): number { + const [r, g, b] = channels(hex).map(linearize); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +export function contrastRatio(a: string, b: string): number { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** + * 把顏色逐步壓暗,直到它在 `bg`(假設是淺色)上達到 `target` 對比。 + * 每個通道乘以遞減的係數,亮度單調下降,所以對比單調上升 —— 迴圈必定收斂。 + * 已達標的顏色原樣回傳。 + */ +export function darkenUntilReadable(color: string, bg: string, target = 4.5): string { + const rgb = channels(color); + const toHex = (factor: number) => + '#' + rgb.map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join(''); + + for (let factor = 1; factor > 0.3; factor -= 0.02) { + const candidate = toHex(factor); + if (contrastRatio(candidate, bg) >= target) return candidate; + } + return toHex(0.3); +} diff --git a/src/lib/og.ts b/src/lib/og.ts new file mode 100644 index 0000000..ce28475 --- /dev/null +++ b/src/lib/og.ts @@ -0,0 +1,9 @@ +import type { Locale } from './i18n'; + +/** + * 分享卡圖的公開路徑。每個工具、每個語系一張,首頁用 `home`。 + * 圖由 `npm run og` 產生並 commit 進 public/;src/lib/__tests__/og.test.ts 守住覆蓋率。 + */ +export function ogImagePath(toolId: string | undefined, locale: Locale): string { + return `/og/${locale}/${toolId ?? 'home'}.png`; +}