diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afb249ab2..6772b3360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1307,6 +1307,9 @@ importers: website: devDependencies: + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 '@rsbuild/plugin-sass': specifier: ^1.5.2 version: 1.5.2(@rsbuild/core@2.0.6) @@ -1349,6 +1352,12 @@ importers: rspress-plugin-sitemap: specifier: ^1.2.1 version: 1.2.1 + satori: + specifier: ^0.26.0 + version: 0.26.0 + satori-html: + specifier: ^0.3.2 + version: 0.3.2 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2755,6 +2764,86 @@ packages: resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rsbuild/core@2.0.6': resolution: {integrity: sha512-0/u7oTgPp9NsL7E7qXzYiOOPAsOJiDbOr0FmG6gizJDIpYK8nospogNrwQ00SG0had9fdhLI7XkhP160IaLnWw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3227,6 +3316,11 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sinclair/typebox@0.34.33': resolution: {integrity: sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g==} @@ -4160,6 +4254,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4322,6 +4420,9 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} @@ -4542,9 +4643,26 @@ packages: cspell-ban-words@0.0.4: resolution: {integrity: sha512-w+18WPFAEmo2F+Fr4L29+GdY5ckOLN95WPwb/arfrtuzzB5VzQRFyIujo0T7pq+xFE0Z2gjfLn33Wk/u5ctNQQ==} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + + css-gradient-parser@0.0.17: + resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==} + engines: {node: '>=16'} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -4743,6 +4861,10 @@ packages: elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@10.5.0: resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} @@ -4842,6 +4964,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -4965,6 +5090,9 @@ packages: picomatch: optional: true + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -5210,6 +5338,10 @@ packages: resolution: {integrity: sha512-VNNu8W4V3Sc64VyLgeQU3uB5PIq0CM4yyD+OKq0zKgMMOOQGftNfCiwDahpFasKYN+RNY9+29jBZUBW8x9O/8A==} hasBin: true + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} @@ -5781,6 +5913,9 @@ packages: resolution: {integrity: sha512-Zvpvd56i9FRV5kaJFiiY1t+FNMEH+dGEaLyQprqKlGHBAxJXmdSk+8tVsh6b9YlxbfyyuLrhJCkzwB+AmOBZ0g==} engines: {node: '>=20'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -6365,6 +6500,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -6376,6 +6514,9 @@ packages: resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==} engines: {node: '>= 0.10'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -6513,6 +6654,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -7069,6 +7213,13 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + satori-html@0.3.2: + resolution: {integrity: sha512-wjTh14iqADFKDK80e51/98MplTGfxz2RmIzh0GqShlf4a67+BooLywF17TvJPD6phO0Hxm7Mf1N5LtRYvdkYRA==} + + satori@0.26.0: + resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==} + engines: {node: '>=16'} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -7309,6 +7460,9 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -7530,6 +7684,9 @@ packages: resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} engines: {node: '>=0.6.0'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@5.1.0: resolution: {integrity: sha512-LXKNtFualiKOm6gADe1UXPtf8+Nfn1CtPMEHAT33Fd2YjQatrujkDcK0+4wRC1X6t7fxUDXUs6BsvuIgfkDgDg==} engines: {node: '>=20.0.0'} @@ -7667,6 +7824,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unbash@3.0.0: resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} engines: {node: '>=14'} @@ -7690,6 +7850,9 @@ packages: unhead@2.1.15: resolution: {integrity: sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA==} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -8000,6 +8163,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -9452,6 +9618,57 @@ snapshots: '@remix-run/router@1.23.2': {} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + 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 + '@rsbuild/core@2.0.6': dependencies: '@rspack/core': 2.0.3(@swc/helpers@0.5.21) @@ -10107,6 +10324,11 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@sinclair/typebox@0.34.33': {} '@sindresorhus/merge-streams@2.3.0': {} @@ -11187,6 +11409,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} base64id@2.0.0: {} @@ -11376,6 +11600,8 @@ snapshots: camelcase@6.3.0: {} + camelize@1.0.1: {} + caniuse-lite@1.0.30001792: {} ccount@2.0.1: {} @@ -11615,6 +11841,14 @@ snapshots: cspell-ban-words@0.0.4: {} + css-background-parser@0.1.0: {} + + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + + css-gradient-parser@0.0.17: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -11623,6 +11857,12 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + css-tree@2.2.1: dependencies: mdn-data: 2.0.28 @@ -11817,6 +12057,8 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + emoji-regex-xs@2.0.1: {} + emoji-regex@10.5.0: {} emoji-regex@8.0.0: {} @@ -11916,6 +12158,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} @@ -12051,6 +12295,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.7.4: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -12387,6 +12633,8 @@ snapshots: heading-case@1.1.0: {} + hex-rgb@4.3.0: {} + hmac-drbg@1.0.1: dependencies: hash.js: 1.1.7 @@ -12955,6 +13203,11 @@ snapshots: dependencies: unicorn-magic: 0.4.0 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lines-and-columns@1.2.4: {} lines-and-columns@2.0.4: {} @@ -13875,6 +14128,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@0.2.9: {} + pako@1.0.11: {} parent-module@1.0.1: @@ -13889,6 +14144,11 @@ snapshots: pbkdf2: 3.1.5 safe-buffer: 5.2.1 + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -14011,6 +14271,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -14582,6 +14844,24 @@ snapshots: '@parcel/watcher': 2.5.6 optional: true + satori-html@0.3.2: + dependencies: + ultrahtml: 1.6.0 + + satori@0.26.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.2.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 + sax@1.6.0: {} saxes@6.0.0: @@ -14864,6 +15144,8 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string.prototype.codepointat@0.2.1: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -15068,6 +15350,8 @@ snapshots: dependencies: setimmediate: 1.0.5 + tiny-inflate@1.0.3: {} + tinybench@5.1.0: {} tinyexec@1.1.2: {} @@ -15171,6 +15455,8 @@ snapshots: uc.micro@2.1.0: {} + ultrahtml@1.6.0: {} + unbash@3.0.0: {} unconfig-core@7.5.0: @@ -15196,6 +15482,11 @@ snapshots: dependencies: hookable: 6.0.1 + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} @@ -15512,6 +15803,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zimmerframe@1.1.4: {} zod@4.3.6: {} diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index fb6e5ad04..616e3d2a9 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -4,6 +4,7 @@ antd apng apos applescript +artboard Asus atrules autodocs @@ -54,6 +55,7 @@ fnames frontends fullhash gzipped +Grotesk icss idents iife @@ -94,6 +96,7 @@ onclosetag onopentag ontext opencode +Optim osascript outbase outro @@ -105,6 +108,7 @@ picocolors pjpeg pluggable pmmmwh +pngquant pnpx postcssrc preact @@ -116,7 +120,9 @@ publint pxtorem quasis quxx +rasterizes rebranded +resvg rolldown rootdir rsbuild @@ -136,6 +142,7 @@ selfsigned sirv sokra speedscope +Squoosh srcset stacktracey styl diff --git a/website/AGENTS.md b/website/AGENTS.md index dee97f8ac..df301d194 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -14,8 +14,36 @@ This is the documentation website for Rstest, built with [Rspress](https://rspre pnpm dev # Start dev server pnpm build # Build for production pnpm preview # Preview production build +pnpm gen:og # Generate a release Open Graph image (see below) ``` +## Open Graph image generation + +Per-release og images live in [rstackjs/rstack-design-resources](https://github.com/rstackjs/rstack-design-resources) and are served by the `assets.rspack.rs` CDN. The template lives **in this repo** to keep design-resources as a passive PNG store. + +- `scripts/og-image/cli.mts` — entry, parses `--version`/`--description`/`--out` +- `scripts/og-image/render.mts` — fetches the Rstest logo SVG → rasterizes → composes with [satori](https://github.com/vercel/satori) → renders with [@resvg/resvg-js](https://github.com/yisibl/resvg-js) at 2x zoom for retina +- `scripts/og-image/template.mts` — [satori-html](https://github.com/natemoo-re/satori-html) template, modeled after the `Rsbuild og image 1.0` artboard in design-resources + +### Release workflow + +1. Run `pnpm gen:og --version --description ""` from `website/`. Use `--out` to write directly into a local clone of the design-resources repo at `rstest/assets/rstest-og-image-v{version-with-hyphens}.png` (e.g. `v0-5.png`). +2. Commit the PNG in the design-resources repo and open a PR — that repo is the only place release PNGs are stored. +3. After CDN deploy, the PNG is reachable at `assets.rspack.rs/rstest/assets/rstest-og-image-v0-5.png`. Wiring it up per blog `routePath` in `rspress.config.ts` is a separate follow-up — the site currently sets a single static `og:image` via `pluginOpenGraph`. + +### Do + +- Use Space Grotesk (committed under `scripts/og-image/assets/fonts/` with SIL OFL license) +- Render at 2x via `Resvg({ fitTo: { mode: 'zoom', value: 2 } })` so the PNG stays crisp on retina displays +- Before committing the PNG to design-resources, run it through [TinyPNG](https://tinypng.com) (or Squoosh / ImageOptim / `pngquant`) — the raw resvg output is ~300 KB and palette quantization typically drops it to ~1/4 the size with no visible loss +- Fetch the logo from the canonical CDN URL at generation time, not from a committed copy + +### Don't + +- Don't depend on packages like `geist` that pull in framework peer deps (`next>=13.2`); commit raw `.ttf` files directly instead +- Don't write generated PNGs into this repo; they belong in design-resources +- Don't bake the logo into a static asset; always fetch the SVG so logo updates propagate automatically + ## Writing style guidelines When writing or editing documentation, follow these principles: diff --git a/website/README.md b/website/README.md index ed81d65a6..e5428200a 100644 --- a/website/README.md +++ b/website/README.md @@ -15,3 +15,7 @@ The same as Rspack: [Writing style guide](https://github.com/web-infra-dev/rspac For images you use in the document, it's better to upload them to the [rstackjs/rstack-design-resources](https://github.com/rstackjs/rstack-design-resources) repository, so the size of the current repository doesn't get too big. After you upload the images there, they will be automatically deployed under the . + +## Open Graph images + +`scripts/og-image/` generates per-release Open Graph images used by the `og:image` and `twitter:image` meta tags on each release blog post. See [AGENTS.md](./AGENTS.md#open-graph-image-generation) for the template architecture and release workflow. diff --git a/website/package.json b/website/package.json index 10b7e7953..0f04fcf01 100644 --- a/website/package.json +++ b/website/package.json @@ -9,9 +9,11 @@ "build": "rspress build", "build:agent-install": "node scripts/buildAgentInstall.mjs", "dev": "rspress dev", + "gen:og": "node --experimental-strip-types scripts/og-image/cli.mts", "preview": "rspress preview" }, "devDependencies": { + "@resvg/resvg-js": "^2.6.2", "@rsbuild/plugin-sass": "^1.5.2", "@rspress/core": "2.0.11", "@rspress/plugin-algolia": "2.0.11", @@ -26,6 +28,8 @@ "rsbuild-plugin-open-graph": "^1.1.2", "rspress-plugin-font-open-sans": "^1.0.3", "rspress-plugin-sitemap": "^1.2.1", + "satori": "^0.26.0", + "satori-html": "^0.3.2", "typescript": "^6.0.3" } } diff --git a/website/scripts/og-image/assets/fonts/LICENSE.txt b/website/scripts/og-image/assets/fonts/LICENSE.txt new file mode 100644 index 000000000..cb512b9af --- /dev/null +++ b/website/scripts/og-image/assets/fonts/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/website/scripts/og-image/assets/fonts/SpaceGrotesk-Bold.ttf b/website/scripts/og-image/assets/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 000000000..f8eb245d1 Binary files /dev/null and b/website/scripts/og-image/assets/fonts/SpaceGrotesk-Bold.ttf differ diff --git a/website/scripts/og-image/assets/fonts/SpaceGrotesk-Regular.ttf b/website/scripts/og-image/assets/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 000000000..46aa5dada Binary files /dev/null and b/website/scripts/og-image/assets/fonts/SpaceGrotesk-Regular.ttf differ diff --git a/website/scripts/og-image/cli.mts b/website/scripts/og-image/cli.mts new file mode 100644 index 000000000..8ec429573 --- /dev/null +++ b/website/scripts/og-image/cli.mts @@ -0,0 +1,47 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { renderOgImage } from './render.mts'; + +const { values } = parseArgs({ + options: { + version: { type: 'string', short: 'v' }, + description: { type: 'string' }, + out: { type: 'string', short: 'o' }, + help: { type: 'boolean', short: 'h' }, + }, +}); + +if (values.help || !values.version) { + console.log(`Usage: pnpm gen:og --version [options] + +Options: + --version, -v Release version, e.g. 0.5 (required) + --description Optional tagline rendered below the version + --out, -o Output PNG path + (default: rstest-og-image-v.png in cwd) + --help, -h Show this help + +After generating, commit the PNG to rstackjs/rstack-design-resources +under rstest/assets/ so the assets.rspack.rs CDN can serve it.`); + process.exit(values.help ? 0 : 1); +} + +const versionSlug = values.version.replace(/\./g, '-'); +const outPath = path.resolve( + values.out ?? `rstest-og-image-v${versionSlug}.png`, +); + +const png = await renderOgImage({ + version: values.version, + description: values.description, +}); + +await mkdir(path.dirname(outPath), { recursive: true }); +await writeFile(outPath, png); + +console.log(`Wrote ${outPath} (${(png.length / 1024).toFixed(1)} KB)`); +console.log( + 'Tip: compress the PNG before committing — typically drops the file to ~1/4 the size with no visible loss.', +); +console.log(' https://tinypng.com (or Squoosh / ImageOptim)'); diff --git a/website/scripts/og-image/render.mts b/website/scripts/og-image/render.mts new file mode 100644 index 000000000..118023701 --- /dev/null +++ b/website/scripts/og-image/render.mts @@ -0,0 +1,204 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Resvg } from '@resvg/resvg-js'; +import satori from 'satori'; +import { buildTemplate, type TemplateOptions } from './template.mts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fontsDir = path.join(here, 'assets/fonts'); + +const LOGO_URL = 'https://assets.rspack.rs/rstest/rstest-logo.svg'; + +// Hues seeded from the rstest mascot palette. Lightness sits mid-range so +// gradients alpha-blend to vivid pastels over white instead of washed-out grey. +const HUE_PALETTE = [ + { h: 160, s: 80, l: 60 }, + { h: 130, s: 70, l: 62 }, + // Yellow's perceived weight is high; cap saturation below the other entries + // so it doesn't dominate the header when it lands there. + { h: 50, s: 75, l: 65 }, + { h: 20, s: 85, l: 65 }, + { h: 220, s: 85, l: 65 }, + { h: 270, s: 75, l: 68 }, +]; + +// Quadrant-anchored blob centers keep gradient mass off the central text +// column (logo, wordmark, v-number) and force multi-blob renders to spread. +type Quadrant = 'TL' | 'TR' | 'BL' | 'BR'; +const QUAD_CENTERS: Record = { + TL: { x: 18, y: 22 }, + TR: { x: 82, y: 22 }, + BL: { x: 18, y: 78 }, + BR: { x: 82, y: 78 }, +}; + +function pickQuadrants(n: number): Quadrant[] { + // For 2 blobs always pick a diagonal pair. Adjacent pairs (TL+TR, TR+BR, + // BL+BR, TL+BL) cause two blobs to stack on the same side of the canvas, + // which looks especially bad in `tonal` scheme where both blobs share a + // hue family. + if (n === 2) { + return Math.random() < 0.5 ? ['TL', 'BR'] : ['TR', 'BL']; + } + const all: Quadrant[] = ['TL', 'TR', 'BL', 'BR']; + for (let i = all.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = all[i]!; + all[i] = all[j]!; + all[j] = tmp; + } + return all.slice(0, n); +} + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]!; +} + +/** + * Build a soft, diffuse background by stacking 1–3 ellipse-shaped radial + * gradients over a white base, modeled on OpenAI's hero gradients. + * + * The end stop must keep the same hue and only drop alpha. Using the + * `transparent` keyword would interpolate RGB toward black (CSS spec: + * `transparent == rgba(0,0,0,0)`) and produce a grey halo around the + * bright center. + */ +function randomBackground(): string { + const base = pick(HUE_PALETTE); + + const schemeRoll = Math.random(); + const scheme: 'tonal' | 'duo' | 'tri' = + schemeRoll < 0.45 ? 'tonal' : schemeRoll < 0.85 ? 'duo' : 'tri'; + + // Off-axis hues sit 90°–150° from the base. A smaller offset (e.g. cyan + + // 60° = lavender) still reads as "same cool/warm family" and the two blobs + // can feel like the same color when placed close together. + const huePool: number[] = [base.h]; + if (scheme === 'duo' || scheme === 'tri') { + huePool.push((base.h + 90 + Math.round(Math.random() * 60) + 360) % 360); + } + if (scheme === 'tri') { + huePool.push((base.h - 90 - Math.round(Math.random() * 60) + 360) % 360); + } + + // Weighted toward 1–2 blobs; 3+ overlapping blobs muddies the gradient. + const blobRoll = Math.random(); + const blobCount = blobRoll < 0.35 ? 1 : blobRoll < 0.85 ? 2 : 3; + const quads = pickQuadrants(blobCount); + + // Single-blob renders need a visibility floor — otherwise a small / dim / + // edge-anchored blob can disappear entirely against the white base. + const { minAlpha, maxAlpha, minSize } = + blobCount === 1 + ? { minAlpha: 0.4, maxAlpha: 0.55, minSize: 50 } + : { minAlpha: 0.3, maxAlpha: 0.5, minSize: 30 }; + + const blobs = quads.map((quad) => { + const seedHue = pick(huePool); + const hueShift = Math.round((Math.random() - 0.5) * 30); // ±15° + const h = (seedHue + hueShift + 360) % 360; + const s = clamp(base.s + Math.round((Math.random() - 0.5) * 20), 50, 95); + const l = clamp(base.l + Math.round((Math.random() - 0.5) * 12), 55, 78); + + const center = QUAD_CENTERS[quad]; + let x = Math.round(center.x + (Math.random() - 0.5) * 30); // anchor ±15% + let y = Math.round(center.y + (Math.random() - 0.5) * 25); // anchor ±12.5% + + const w = Math.round(minSize + Math.random() * (85 - minSize)); + // Cap blob aspect ratio at ~1.4:1 so blobs read as soft ovals rather than + // long bars. Independent w/h sampling can otherwise produce ~2.8:1. + const MAX_RATIO = 1.4; + const hLo = Math.max(minSize, w / MAX_RATIO); + const hHi = Math.min(85, w * MAX_RATIO); + const h2 = Math.round(hLo + Math.random() * (hHi - hLo)); + + const alpha = (minAlpha + Math.random() * (maxAlpha - minAlpha)).toFixed(2); + // Tighter end-stop keeps the alpha falloff crisp. Long tails (>=80%) + // produce a "dingy beige/grey" halo for warm hues because pale tinted + // pixels read as muddy when they cover a wide area over white. + const endStop = Math.round(55 + Math.random() * 20); // 55% – 75% + + // Even within the 1.4:1 cap, an elongated blob whose long edges both sit + // inside the canvas reads as a "bar" floating mid-frame. Hide one long + // edge by pulling the blob's short-axis center toward the nearer canvas + // edge — the result is a soft wash spilling in from that edge. + const ASYM = 1.15; + if (w / h2 > ASYM) { + y = edgeHugCenter((h2 * endStop) / 100, center.y); + } else if (h2 / w > ASYM) { + x = edgeHugCenter((w * endStop) / 100, center.x); + } + + const start = `hsla(${h}, ${s}%, ${l}%, ${alpha})`; + const end = `hsla(${h}, ${s}%, ${l}%, 0)`; + return `radial-gradient(ellipse ${w}% ${h2}% at ${x}% ${y}%, ${start}, ${end} ${endStop}%)`; + }); + return `${blobs.join(', ')}, #ffffff`; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** + * Place a blob's short-axis center near the nearer canvas edge (top vs bottom, + * or left vs right, depending on `anchor`) so one long edge of an elongated + * blob clips off-canvas instead of leaving both long edges visible mid-frame. + * + * `visibleSemi` is the visible semi-axis along the short axis + * (`shortDim * endStop / 100`). The -8 keeps the gradient core slightly inside + * the canvas so the wash still reads after the edge clip. + */ +function edgeHugCenter(visibleSemi: number, anchor: number): number { + const maxOffset = Math.max(0, visibleSemi - 8); + return anchor < 50 + ? Math.round(Math.random() * maxOffset) + : Math.round(100 - Math.random() * maxOffset); +} + +async function fetchLogoAsPng(): Promise { + const response = await fetch(LOGO_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch logo from ${LOGO_URL}: ${response.status} ${response.statusText}`, + ); + } + const svgText = await response.text(); + // satori does not rasterize , so rasterize the logo to PNG + // first. 512px source stays crisp when the og PNG is rendered at 2x. + const resvg = new Resvg(svgText, { fitTo: { mode: 'width', value: 512 } }); + return resvg.render().asPng(); +} + +export type RenderOptions = Omit; + +export async function renderOgImage(opts: RenderOptions): Promise { + const [regular, bold, logoPng] = await Promise.all([ + readFile(path.join(fontsDir, 'SpaceGrotesk-Regular.ttf')), + readFile(path.join(fontsDir, 'SpaceGrotesk-Bold.ttf')), + fetchLogoAsPng(), + ]); + + const logoDataUrl = `data:image/png;base64,${logoPng.toString('base64')}`; + const tree = buildTemplate({ + ...opts, + logoDataUrl, + background: randomBackground(), + }); + + const svg = await satori(tree, { + width: 1200, + height: 630, + fonts: [ + { name: 'Space Grotesk', data: regular, weight: 400, style: 'normal' }, + { name: 'Space Grotesk', data: bold, weight: 700, style: 'normal' }, + ], + }); + + // Render at 2x for crisp output on high-DPI displays (Twitter/Facebook + // accept any reasonable aspect-correct size; 2400x1260 is well within their + // ~5MB / 8192px limits). + const resvg = new Resvg(svg, { fitTo: { mode: 'zoom', value: 2 } }); + return resvg.render().asPng(); +} diff --git a/website/scripts/og-image/template.mts b/website/scripts/og-image/template.mts new file mode 100644 index 000000000..b0808cf87 --- /dev/null +++ b/website/scripts/og-image/template.mts @@ -0,0 +1,104 @@ +import { html } from 'satori-html'; + +export interface TemplateOptions { + version: string; + description?: string; + logoDataUrl: string; + background: string; +} + +/** + * Layout: 1200x630. + * + * Centered column over a white + soft-gradient background: + * - Header row: logo + "Rstest" wordmark side-by-side + * - v{version} (display-sized hero) + * - Description (free-form tagline) + * + * design-resources has no rstest "logo + wordmark" combo SVG yet, so the + * wordmark is rendered separately in Space Grotesk while the logo is fetched + * at runtime from the canonical CDN URL. + * + * satori quirk: every container needs an explicit `display: flex`, since + * satori treats undeclared display as `none`. + */ +export function buildTemplate({ + version, + description, + logoDataUrl, + background, +}: TemplateOptions) { + return html` +
+
+ +
+ Rstest +
+
+ +
+ v${version} +
+ +
+ ${description ?? ''} +
+
+ `; +}