diff --git a/.gitignore b/.gitignore index 938c6fae1..da33a439b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ test-results .eslintcache .tsbuildinfo src/routeTree.gen.ts +.og-preview/ diff --git a/netlify.toml b/netlify.toml index d54ba1cf4..30e5d7293 100644 --- a/netlify.toml +++ b/netlify.toml @@ -7,6 +7,11 @@ publish = "dist/client" [functions] directory = "netlify/functions" +included_files = [ + "public/fonts/Inter-Regular.ttf", + "public/fonts/Inter-ExtraBold.ttf", + "public/images/logos/splash-dark.png", +] [[headers]] for = "/*" diff --git a/package.json b/package.json index b22d7b667..e18b73b0f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@resvg/resvg-js": "^2.6.2", "@sentry/browser": "^10.47.0", "@sentry/node": "^10.47.0", "@sentry/tanstackstart-react": "^10.47.0", @@ -102,6 +103,7 @@ "remark-rehype": "^11.1.2", "remove-markdown": "^0.6.3", "resend": "^6.10.0", + "satori": "^0.26.0", "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", "tar-stream": "^3.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eba7abb83..99dcf8f2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@react-three/fiber': specifier: ^9.5.0 version: 9.5.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.183.2) + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 '@sentry/browser': specifier: ^10.47.0 version: 10.47.0 @@ -239,6 +242,9 @@ importers: resend: specifier: ^6.10.0 version: 6.10.0 + satori: + specifier: ^0.26.0 + version: 0.26.0 shiki: specifier: ^4.0.2 version: 4.0.2 @@ -2846,6 +2852,86 @@ packages: react-native: optional: true + '@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'} + '@rolldown/binding-android-arm64@1.0.0-rc.12': resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3276,6 +3362,11 @@ packages: react-dom: ^18.3.1 || ~19.0.3 || ~19.1.4 || ^19.2.3 vite: ^5.1.0 || ^6.2.1 + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -4276,6 +4367,10 @@ packages: bare-url@2.4.0: resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} + 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==} @@ -4377,6 +4472,9 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + camera-controls@3.1.2: resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} engines: {node: '>=22.0.0', npm: '>=10.5.1'} @@ -4630,9 +4728,26 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + 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'} @@ -5116,6 +5231,10 @@ packages: electron-to-chromium@1.5.330: resolution: {integrity: sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5366,6 +5485,9 @@ packages: fflate@0.6.10: resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -5643,6 +5765,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + hls.js@1.6.15: resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} @@ -6224,6 +6350,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -6830,6 +6959,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==} @@ -6837,6 +6969,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -7005,6 +7140,9 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss-values-parser@6.0.2: resolution: {integrity: sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==} engines: {node: '>=10'} @@ -7414,6 +7552,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + 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'} @@ -7606,6 +7748,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string.prototype.padend@3.1.6: resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==} engines: {node: '>= 0.4'} @@ -7749,6 +7894,9 @@ packages: three@0.183.2: resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -7902,6 +8050,9 @@ packages: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} + 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'} @@ -8355,6 +8506,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==} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -10773,6 +10927,57 @@ snapshots: - '@types/react' - immer + '@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 + '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true @@ -11157,6 +11362,11 @@ snapshots: transitivePeerDependencies: - three + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@sindresorhus/merge-streams@4.0.0': {} '@so-ric/colorspace@1.1.6': @@ -12344,6 +12554,8 @@ snapshots: dependencies: bare-path: 3.0.0 + base64-js@0.0.8: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.13: {} @@ -12451,6 +12663,8 @@ snapshots: camelcase@8.0.0: {} + camelize@1.0.1: {} + camera-controls@3.1.2(three@0.183.2): dependencies: three: 0.183.2 @@ -12712,6 +12926,14 @@ snapshots: dependencies: uncrypto: 0.1.3 + 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 @@ -12720,6 +12942,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 @@ -13150,6 +13378,8 @@ snapshots: electron-to-chromium@1.5.330: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@8.0.0: {} empathic@2.0.0: {} @@ -13560,6 +13790,8 @@ snapshots: fflate@0.6.10: {} + fflate@0.7.4: {} + fflate@0.8.2: {} figures@6.1.0: @@ -13898,6 +14130,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hex-rgb@4.3.0: {} + hls.js@1.6.15: {} hogan.js@3.0.2: @@ -14468,6 +14702,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lines-and-columns@1.2.4: optional: true @@ -15348,6 +15587,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@0.2.9: {} + pako@1.0.11: {} parent-module@1.0.1: @@ -15355,6 +15596,11 @@ snapshots: callsites: 3.1.0 optional: true + 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 @@ -15504,6 +15750,8 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss-value-parser@4.2.0: {} + postcss-values-parser@6.0.2(postcss@8.5.8): dependencies: color-name: 1.1.4 @@ -16041,6 +16289,20 @@ snapshots: safer-buffer@2.1.2: {} + 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: {} scheduler@0.27.0: {} @@ -16291,6 +16553,8 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string.prototype.codepointat@0.2.1: {} + string.prototype.padend@3.1.6: dependencies: call-bind: 1.0.8 @@ -16466,6 +16730,8 @@ snapshots: three@0.183.2: {} + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tinyexec@1.0.4: {} @@ -16612,6 +16878,11 @@ snapshots: undici@7.24.7: {} + 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: {} @@ -16982,6 +17253,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 diff --git a/public/fonts/Inter-ExtraBold.ttf b/public/fonts/Inter-ExtraBold.ttf new file mode 100644 index 000000000..8a9a1bcb9 Binary files /dev/null and b/public/fonts/Inter-ExtraBold.ttf differ diff --git a/public/fonts/Inter-Regular.ttf b/public/fonts/Inter-Regular.ttf new file mode 100644 index 000000000..399a6e0c3 Binary files /dev/null and b/public/fonts/Inter-Regular.ttf differ diff --git a/scripts/og-preview.ts b/scripts/og-preview.ts new file mode 100644 index 000000000..034315865 --- /dev/null +++ b/scripts/og-preview.ts @@ -0,0 +1,44 @@ +/** + * Renders one OG image per library to `.og-preview/`. + * Run with: pnpm exec tsx scripts/og-preview.ts + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { libraries } from '../src/libraries/libraries' +import { generateOgPng } from '../src/server/og/generate.server' + +const OUT_DIR = resolve(process.cwd(), '.og-preview') + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }) + + for (const lib of libraries) { + if (!lib.to) continue // skip entries without a landing page (react-charts, create-tsrouter-app) + + // Landing-page variant (no explicit title/description) + const landing = await generateOgPng({ libraryId: lib.id }) + if ('kind' in (landing as Record)) { + console.warn(`[skip] ${lib.id}: ${(landing as any).kind}`) + continue + } + const landingPath = resolve(OUT_DIR, `${lib.id}.png`) + writeFileSync(landingPath, landing as Buffer) + console.log(`[ok] ${landingPath}`) + + // Docs-page variant (simulate a per-page title + description) + const docs = await generateOgPng({ + libraryId: lib.id, + title: 'Overview', + description: `${lib.tagline} Guides, API reference and examples in one place.`, + }) + if ('kind' in (docs as Record)) continue + const docsPath = resolve(OUT_DIR, `${lib.id}-docs.png`) + writeFileSync(docsPath, docs as Buffer) + console.log(`[ok] ${docsPath}`) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/libraries/ai.tsx b/src/libraries/ai.tsx index 99f456bf8..967b6a5b5 100644 --- a/src/libraries/ai.tsx +++ b/src/libraries/ai.tsx @@ -9,7 +9,6 @@ const textStyles = `text-pink-600 dark:text-pink-500` export const aiProject = { ...ai, description: `A powerful, open-source AI SDK with a unified interface across multiple providers. No vendor lock-in, no proprietary formats, just clean TypeScript and honest open source.`, - ogImage: 'https://github.com/tanstack/ai/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-pink-500 via-pink-700/50 to-transparent', textColor: `text-pink-700`, diff --git a/src/libraries/config.tsx b/src/libraries/config.tsx index ff300d146..a70663b77 100644 --- a/src/libraries/config.tsx +++ b/src/libraries/config.tsx @@ -8,7 +8,6 @@ const textStyles = 'text-black dark:text-gray-100' export const configProject = { ...config, description: `Opinionated tooling to lint, build, test, version, and publish JS/TS packages โ€” minimal config, consistent results.`, - ogImage: 'https://github.com/tanstack/config/raw/main/media/repo-header.png', latestBranch: 'main', featureHighlights: [ { diff --git a/src/libraries/db.tsx b/src/libraries/db.tsx index e7180a76a..38b21db9b 100644 --- a/src/libraries/db.tsx +++ b/src/libraries/db.tsx @@ -8,7 +8,6 @@ const textStyles = `text-orange-600 dark:text-orange-500` export const dbProject = { ...db, description: `TanStack DB gives you a reactive, client-first store for your API data with collections, live queries and optimistic mutations that keep your UI reactive, consistent and blazing fast ๐Ÿ”ฅ`, - ogImage: 'https://github.com/tanstack/db/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-orange-500 via-orange-700/50 to-transparent', textColor: `text-orange-700`, diff --git a/src/libraries/devtools.tsx b/src/libraries/devtools.tsx index 417253ba2..0a215277c 100644 --- a/src/libraries/devtools.tsx +++ b/src/libraries/devtools.tsx @@ -8,8 +8,6 @@ const textStyles = 'text-black dark:text-gray-100' export const devtoolsProject = { ...devtools, description: `A unified devtools panel that houses all TanStack devtools and allows you to create and integrate your own custom devtools.`, - ogImage: - 'https://github.com/tanstack/devtools/raw/main/media/repo-header.png', latestBranch: 'main', featureHighlights: [ { diff --git a/src/libraries/form.tsx b/src/libraries/form.tsx index 610aae95a..b96b18c46 100644 --- a/src/libraries/form.tsx +++ b/src/libraries/form.tsx @@ -8,7 +8,6 @@ const textStyles = 'text-yellow-600 dark:text-yellow-300' export const formProject = { ...form, description: `Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit and Svelte.`, - ogImage: 'https://github.com/tanstack/form/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-yellow-500 via-yellow-600/50 to-transparent', textColor: 'text-yellow-600', diff --git a/src/libraries/hotkeys.tsx b/src/libraries/hotkeys.tsx index bc595b94d..b1382e40c 100644 --- a/src/libraries/hotkeys.tsx +++ b/src/libraries/hotkeys.tsx @@ -7,7 +7,6 @@ const textStyles = 'text-rose-600 dark:text-rose-500' export const hotkeysProject = { ...hotkeys, description: `A type-safe, cross-platform hotkey library with sequence detection, key state tracking, hotkey recording, and framework adapters for React and more.`, - ogImage: 'https://github.com/tanstack/hotkeys/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-rose-500 via-rose-700/50 to-transparent', textColor: 'text-rose-700', diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts index 53a2fa926..52288f609 100644 --- a/src/libraries/libraries.ts +++ b/src/libraries/libraries.ts @@ -28,7 +28,6 @@ export const query: LibrarySlim = { latestBranch: 'main', availableVersions: ['v5', 'v4', 'v3'], scarfId: '53afb586-3934-4624-a37a-e680c1528e17', - ogImage: 'https://github.com/tanstack/query/raw/main/media/repo-header.png', defaultDocs: 'framework/react/overview', sitemap: { includeLandingPage: true, @@ -219,7 +218,6 @@ export const router: LibrarySlim = { latestBranch: 'main', availableVersions: ['v1'], scarfId: '3d14fff2-f326-4929-b5e1-6ecf953d24f4', - ogImage: 'https://github.com/tanstack/router/raw/main/media/header.png', docsRoot: 'docs/router', sitemap: { includeLandingPage: true, @@ -333,7 +331,6 @@ export const table: LibrarySlim = { latestBranch: 'main', availableVersions: ['v8', 'alpha'], scarfId: 'dc8b39e1-3fe9-4f3a-8e56-d4e2cf420a9e', - ogImage: 'https://github.com/tanstack/table/raw/main/media/repo-header.png', defaultDocs: 'introduction', sitemap: { includeLandingPage: true, @@ -407,7 +404,6 @@ export const form: LibrarySlim = { latestBranch: 'main', availableVersions: ['v1'], scarfId: '72ec4452-5d77-427c-b44a-57515d2d83aa', - ogImage: 'https://github.com/tanstack/form/raw/main/media/repo-header.png', sitemap: { includeLandingPage: true, includeTopLevelDocsPages: true, @@ -437,7 +433,6 @@ export const virtual: LibrarySlim = { latestBranch: 'main', availableVersions: ['v3'], scarfId: '32372eb1-91e0-48e7-8df1-4808a7be6b94', - ogImage: 'https://github.com/tanstack/query/raw/main/media/header.png', defaultDocs: 'introduction', legacyPackages: ['react-virtual'], } @@ -468,7 +463,6 @@ export const ranger: LibrarySlim = { latestBranch: 'main', availableVersions: ['v0'], scarfId: 'dd278e06-bb3f-420c-85c6-6e42d14d8f61', - ogImage: 'https://github.com/tanstack/ranger/raw/main/media/headerv1.png', } export const store: LibrarySlim = { @@ -493,7 +487,6 @@ export const store: LibrarySlim = { latestBranch: 'main', availableVersions: ['v0'], scarfId: '302d0fef-cb3f-43c6-b45c-f055b9745edb', - ogImage: 'https://github.com/tanstack/store/raw/main/media/repo-header.png', defaultDocs: 'overview', } @@ -521,7 +514,6 @@ export const pacer: LibrarySlim = { latestBranch: 'main', availableVersions: ['v0'], scarfId: '302d0fef-cb3f-43c6-b45c-f055b9745edb', - ogImage: 'https://github.com/tanstack/pacer/raw/main/media/repo-header.png', defaultDocs: 'overview', } @@ -548,7 +540,6 @@ export const hotkeys: LibrarySlim = { latestVersion: 'v0', latestBranch: 'main', availableVersions: ['v0'], - ogImage: 'https://github.com/tanstack/hotkeys/raw/main/media/repo-header.png', defaultDocs: 'overview', } @@ -574,7 +565,6 @@ export const db: LibrarySlim = { latestBranch: 'main', availableVersions: ['v0'], scarfId: '302d0fef-cb3f-43c6-b45c-f055b9745edb', - ogImage: 'https://github.com/tanstack/db/raw/main/media/repo-header.png', defaultDocs: 'overview', sitemap: { includeLandingPage: true, @@ -603,7 +593,6 @@ export const ai: LibrarySlim = { latestVersion: 'v0', latestBranch: 'main', availableVersions: ['v0'], - ogImage: 'https://github.com/tanstack/ai/raw/main/media/repo-header.png', defaultDocs: 'getting-started/overview', } @@ -628,7 +617,6 @@ export const intent: LibrarySlim = { latestVersion: 'v0', latestBranch: 'main', availableVersions: ['v0'], - ogImage: 'https://github.com/tanstack/intent/raw/main/media/repo-header.png', defaultDocs: 'overview', } @@ -658,7 +646,6 @@ export const config: LibrarySlim = { latestVersion: 'v0', latestBranch: 'main', availableVersions: ['v0'], - ogImage: 'https://github.com/tanstack/config/raw/main/media/repo-header.png', } export const devtools: LibrarySlim = { @@ -688,8 +675,6 @@ export const devtools: LibrarySlim = { latestVersion: 'v0', latestBranch: 'main', availableVersions: ['v0'], - ogImage: - 'https://github.com/tanstack/devtools/raw/main/media/repo-header.png', } export const mcp: LibrarySlim = { @@ -748,7 +733,6 @@ export const cli: LibrarySlim = { latestVersion: 'v0', latestBranch: 'main', availableVersions: ['v0'], - ogImage: 'https://github.com/tanstack/cli/raw/main/media/repo-header.png', defaultDocs: 'overview', } diff --git a/src/libraries/pacer.tsx b/src/libraries/pacer.tsx index d347dc4a9..645f6f6bd 100644 --- a/src/libraries/pacer.tsx +++ b/src/libraries/pacer.tsx @@ -7,7 +7,6 @@ const textStyles = `text-lime-600 dark:text-lime-500` export const pacerProject = { ...pacer, description: `Optimize your application's performance with TanStack Pacer's core primitives: Debouncing, Throttling, Rate Limiting, Queuing, and Batching.`, - ogImage: 'https://github.com/tanstack/pacer/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-lime-500 via-lime-700/50 to-transparent', textColor: `text-lime-700`, diff --git a/src/libraries/query.tsx b/src/libraries/query.tsx index e00926e57..de697d2a2 100644 --- a/src/libraries/query.tsx +++ b/src/libraries/query.tsx @@ -9,7 +9,6 @@ export const queryProject = { ...query, description: 'Powerful asynchronous state management, server-state utilities and data fetching. Fetch, cache, update, and wrangle all forms of async data in your TS/JS, React, Vue, Solid, Svelte & Angular applications all without touching any "global state"', - ogImage: 'https://github.com/tanstack/query/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-red-500 via-red-500/60 to-transparent', textColor: 'text-amber-500', diff --git a/src/libraries/ranger.tsx b/src/libraries/ranger.tsx index 9a938e48d..df971a090 100644 --- a/src/libraries/ranger.tsx +++ b/src/libraries/ranger.tsx @@ -7,7 +7,6 @@ const textStyles = 'text-black dark:text-gray-100' export const rangerProject = { ...ranger, description: `Headless, lightweight, and extensible primitives for building range and multi-range sliders.`, - ogImage: 'https://github.com/tanstack/ranger/raw/main/media/headerv1.png', latestBranch: 'main', featureHighlights: [ { diff --git a/src/libraries/router.tsx b/src/libraries/router.tsx index f2fa76c5a..702fd7d62 100644 --- a/src/libraries/router.tsx +++ b/src/libraries/router.tsx @@ -7,7 +7,6 @@ const textStyles = 'text-emerald-500 dark:text-emerald-400' export const routerProject = { ...router, description: `A powerful React router for client-side and full-stack react applications. Fully type-safe APIs, first-class search-params for managing state in the URL and seamless integration with the existing React ecosystem.`, - ogImage: 'https://github.com/tanstack/router/raw/main/media/header.png', latestBranch: 'main', docsRoot: 'docs/router', bgRadial: 'from-emerald-500 via-lime-600/50 to-transparent', diff --git a/src/libraries/store.tsx b/src/libraries/store.tsx index 64cd9918d..424f33072 100644 --- a/src/libraries/store.tsx +++ b/src/libraries/store.tsx @@ -7,7 +7,6 @@ const textStyles = 'text-twine-600 dark:text-twine-500' export const storeProject = { ...store, description: `The immutable-reactive data store that powers the core of TanStack libraries and their framework adapters.`, - ogImage: 'https://github.com/tanstack/store/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-twine-500 via-twine-700/50 to-transparent', textColor: 'text-twine-700', diff --git a/src/libraries/table.tsx b/src/libraries/table.tsx index c9535008d..238a8907f 100644 --- a/src/libraries/table.tsx +++ b/src/libraries/table.tsx @@ -8,7 +8,6 @@ const textStyles = 'text-blue-500 dark:text-blue-400' export const tableProject = { ...table, description: `Supercharge your tables or build a datagrid from scratch for TS/JS, React, Vue, Solid, Svelte, Qwik, Angular, and Lit while retaining 100% control over markup and styles.`, - ogImage: 'https://github.com/tanstack/table/raw/main/media/repo-header.png', latestBranch: 'main', bgRadial: 'from-cyan-500 via-blue-600/50 to-transparent', textColor: 'text-blue-600', diff --git a/src/libraries/types.ts b/src/libraries/types.ts index b6debb7b3..a19340449 100644 --- a/src/libraries/types.ts +++ b/src/libraries/types.ts @@ -61,7 +61,6 @@ export type LibrarySlim = { scarfId?: string defaultDocs?: string docsRoot?: string - ogImage?: string hideCodesandboxUrl?: true hideStackblitzUrl?: true showVercelUrl?: boolean diff --git a/src/libraries/virtual.tsx b/src/libraries/virtual.tsx index 87391aef7..57a47c64c 100644 --- a/src/libraries/virtual.tsx +++ b/src/libraries/virtual.tsx @@ -8,7 +8,6 @@ const textStyles = 'text-violet-700 dark:text-violet-400' export const virtualProject = { ...virtual, description: `Virtualize only the visible content for massive scrollable DOM nodes at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular while retaining 100% control over markup and styles.`, - ogImage: 'https://github.com/tanstack/query/raw/main/media/header.png', latestBranch: 'main', bgRadial: 'from-purple-500 via-violet-600/50 to-transparent', textColor: 'text-purple-600', diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 103d0b56c..10ab9eacd 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -108,6 +108,7 @@ import { Route as ShopPagesHandleRouteImport } from './routes/shop.pages.$handle import { Route as ShopCollectionsHandleRouteImport } from './routes/shop.collections.$handle' import { Route as IntentRegistryPackageNameRouteImport } from './routes/intent/registry/$packageName' import { Route as AuthProviderStartRouteImport } from './routes/auth/$provider/start' +import { Route as ApiOgLibraryDotpngRouteImport } from './routes/api/og/$library[.png]' import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$' import { Route as ApiGithubWebhookRouteImport } from './routes/api/github/webhook' import { Route as ApiExampleDeployRouteImport } from './routes/api/example/deploy' @@ -647,6 +648,11 @@ const AuthProviderStartRoute = AuthProviderStartRouteImport.update({ path: '/auth/$provider/start', getParentRoute: () => rootRouteImport, } as any) +const ApiOgLibraryDotpngRoute = ApiOgLibraryDotpngRouteImport.update({ + id: '/api/og/$library.png', + path: '/api/og/$library.png', + getParentRoute: () => rootRouteImport, +} as any) const ApiMcpSplatRoute = ApiMcpSplatRouteImport.update({ id: '/api/mcp/$', path: '/api/mcp/$', @@ -964,6 +970,7 @@ export interface FileRoutesByFullPath { '/api/example/deploy': typeof ApiExampleDeployRoute '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute + '/api/og/$library.png': typeof ApiOgLibraryDotpngRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren '/shop/collections/$handle': typeof ShopCollectionsHandleRoute @@ -1097,6 +1104,7 @@ export interface FileRoutesByTo { '/api/example/deploy': typeof ApiExampleDeployRoute '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute + '/api/og/$library.png': typeof ApiOgLibraryDotpngRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/shop/collections/$handle': typeof ShopCollectionsHandleRoute '/shop/pages/$handle': typeof ShopPagesHandleRoute @@ -1239,6 +1247,7 @@ export interface FileRoutesById { '/api/example/deploy': typeof ApiExampleDeployRoute '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute + '/api/og/$library.png': typeof ApiOgLibraryDotpngRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren '/shop/collections/$handle': typeof ShopCollectionsHandleRoute @@ -1383,6 +1392,7 @@ export interface FileRouteTypes { | '/api/example/deploy' | '/api/github/webhook' | '/api/mcp/$' + | '/api/og/$library.png' | '/auth/$provider/start' | '/intent/registry/$packageName' | '/shop/collections/$handle' @@ -1516,6 +1526,7 @@ export interface FileRouteTypes { | '/api/example/deploy' | '/api/github/webhook' | '/api/mcp/$' + | '/api/og/$library.png' | '/auth/$provider/start' | '/shop/collections/$handle' | '/shop/pages/$handle' @@ -1657,6 +1668,7 @@ export interface FileRouteTypes { | '/api/example/deploy' | '/api/github/webhook' | '/api/mcp/$' + | '/api/og/$library.png' | '/auth/$provider/start' | '/intent/registry/$packageName' | '/shop/collections/$handle' @@ -1771,6 +1783,7 @@ export interface RootRouteChildren { ApiExampleDeployRoute: typeof ApiExampleDeployRoute ApiGithubWebhookRoute: typeof ApiGithubWebhookRoute ApiMcpSplatRoute: typeof ApiMcpSplatRoute + ApiOgLibraryDotpngRoute: typeof ApiOgLibraryDotpngRoute AuthProviderStartRoute: typeof AuthProviderStartRoute IntentRegistryPackageNameRoute: typeof IntentRegistryPackageNameRouteWithChildren ShowcaseEditIdRoute: typeof ShowcaseEditIdRoute @@ -2496,6 +2509,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthProviderStartRouteImport parentRoute: typeof rootRouteImport } + '/api/og/$library.png': { + id: '/api/og/$library.png' + path: '/api/og/$library.png' + fullPath: '/api/og/$library.png' + preLoaderRoute: typeof ApiOgLibraryDotpngRouteImport + parentRoute: typeof rootRouteImport + } '/api/mcp/$': { id: '/api/mcp/$' path: '/api/mcp/$' @@ -3065,6 +3085,7 @@ const rootRouteChildren: RootRouteChildren = { ApiExampleDeployRoute: ApiExampleDeployRoute, ApiGithubWebhookRoute: ApiGithubWebhookRoute, ApiMcpSplatRoute: ApiMcpSplatRoute, + ApiOgLibraryDotpngRoute: ApiOgLibraryDotpngRoute, AuthProviderStartRoute: AuthProviderStartRoute, IntentRegistryPackageNameRoute: IntentRegistryPackageNameRouteWithChildren, ShowcaseEditIdRoute: ShowcaseEditIdRoute, diff --git a/src/routes/$libraryId/$version.docs.$.tsx b/src/routes/$libraryId/$version.docs.$.tsx index ee525cec5..a258c0e16 100644 --- a/src/routes/$libraryId/$version.docs.$.tsx +++ b/src/routes/$libraryId/$version.docs.$.tsx @@ -1,4 +1,5 @@ import { seo } from '~/utils/seo' +import { ogImageUrl } from '~/utils/og' import { Doc } from '~/components/Doc' import { loadDocsPage, resolveDocsRedirect } from '~/utils/docs' import { findLibrary, getBranch, getLibrary } from '~/libraries' @@ -72,6 +73,10 @@ export const Route = createFileRoute('/$libraryId/$version/docs/$')({ meta: seo({ title: `${loaderData?.title} | ${library.name} Docs`, description: loaderData?.description, + image: ogImageUrl(library.id, { + title: loaderData?.title, + description: loaderData?.description, + }), noindex: library.visible === false, }), } diff --git a/src/routes/$libraryId/$version.docs.community-resources.tsx b/src/routes/$libraryId/$version.docs.community-resources.tsx index 3b1f7638a..a26d64cfa 100644 --- a/src/routes/$libraryId/$version.docs.community-resources.tsx +++ b/src/routes/$libraryId/$version.docs.community-resources.tsx @@ -4,6 +4,7 @@ import { DocContainer } from '~/components/DocContainer' import { DocTitle } from '~/components/DocTitle' import { findLibrary, getBranch, getLibrary } from '~/libraries' import { seo } from '~/utils/seo' +import { ogImageUrl } from '~/utils/og' import { loadDocs } from '~/utils/docs' export const Route = createFileRoute( @@ -37,6 +38,9 @@ export const Route = createFileRoute( meta: seo({ title: `${library.name} Community Resources`, description: `A collection of community resources for ${library.name}.`, + image: ogImageUrl(library.id, { + title: `${library.name} ยท Community Resources`, + }), noindex: library.visible === false, }), } diff --git a/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx b/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx index b0eb34fb6..40bba4c45 100644 --- a/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx +++ b/src/routes/$libraryId/$version.docs.framework.$framework.$.tsx @@ -6,6 +6,7 @@ import { createFileRoute, } from '@tanstack/react-router' import { seo } from '~/utils/seo' +import { ogImageUrl } from '~/utils/og' import { Doc } from '~/components/Doc' import { loadDocsPage, resolveDocsRedirect } from '~/utils/docs' import { getBranch, getLibrary } from '~/libraries' @@ -75,6 +76,10 @@ export const Route = createFileRoute( ? `${ctx.loaderData.title} | ${tail}` : tail, description: ctx.loaderData?.description, + image: ogImageUrl(library.id, { + title: ctx.loaderData?.title, + description: ctx.loaderData?.description, + }), noindex: library.visible === false, }), } diff --git a/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx b/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx index 67629d332..ea975cf80 100644 --- a/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx +++ b/src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx @@ -13,6 +13,7 @@ import { getExampleStartingPath, } from '~/utils/sandbox' import { seo } from '~/utils/seo' +import { ogImageUrl } from '~/utils/og' import { capitalize, slugToTitle } from '~/utils/utils' import * as v from 'valibot' import { CodeExplorer } from '~/components/CodeExplorer' @@ -127,6 +128,10 @@ export const Route = createFileRoute( description: `An example showing how to implement ${slugToTitle( params._splat || '', )} in ${capitalize(params.framework)} using ${library.name}.`, + image: ogImageUrl(library.id, { + title: `${capitalize(params.framework)} ${library.name} ${slugToTitle(params._splat || '')} Example`, + description: `An example showing how to implement ${slugToTitle(params._splat || '')} in ${capitalize(params.framework)} using ${library.name}.`, + }), noindex: library.visible === false, }), } diff --git a/src/routes/$libraryId/$version.index.tsx b/src/routes/$libraryId/$version.index.tsx index 99aecc99a..ae1a2ebba 100644 --- a/src/routes/$libraryId/$version.index.tsx +++ b/src/routes/$libraryId/$version.index.tsx @@ -7,6 +7,7 @@ import { import { DocsLayout } from '~/components/DocsLayout' import { findLibrary } from '~/libraries' import { seo } from '~/utils/seo' +import { ogImageUrl } from '~/utils/og' import { Button } from '~/ui' @@ -25,6 +26,7 @@ export const Route = createFileRoute('/$libraryId/$version/')({ meta: seo({ title: library.name, description: library.description, + image: ogImageUrl(library.id), noindex: library.visible === false, }), } diff --git a/src/routes/$libraryId/route.tsx b/src/routes/$libraryId/route.tsx index d61be60e0..41c3e39c6 100644 --- a/src/routes/$libraryId/route.tsx +++ b/src/routes/$libraryId/route.tsx @@ -3,6 +3,7 @@ import { notFound, Outlet, useParams } from '@tanstack/react-router' import { Scarf } from '~/components/Scarf' import { findLibrary, LibraryId } from '~/libraries' import { seo } from '~/utils/seo' +import { ogImageUrl } from '~/utils/og' export const Route = createFileRoute('/$libraryId')({ params: { @@ -48,7 +49,7 @@ export const Route = createFileRoute('/$libraryId')({ .join(', ')}` : '', description: library.description, - image: library.ogImage, + image: ogImageUrl(library.id), noindex: library.visible === false, }), } diff --git a/src/routes/-library-landing.tsx b/src/routes/-library-landing.tsx index d6568a56a..145e93917 100644 --- a/src/routes/-library-landing.tsx +++ b/src/routes/-library-landing.tsx @@ -8,6 +8,7 @@ import type { LibraryId } from '~/libraries' import { getTanstackDocsConfig } from '~/utils/config' import { fetchLandingCodeExample } from '~/utils/landing-code-example.functions' import { seo } from '~/utils/seo' +import { ogImageUrl } from '~/utils/og' export type LandingComponentProps = { landingCodeExampleRsc?: ReactNode @@ -176,7 +177,7 @@ export function createLibraryLandingPage( meta: seo({ title: library.name, description: library.description, - image: library.ogImage, + image: ogImageUrl(library.id), noindex: library.visible === false, }), }), diff --git a/src/routes/api/og/$library[.png].ts b/src/routes/api/og/$library[.png].ts new file mode 100644 index 000000000..06365fe3c --- /dev/null +++ b/src/routes/api/og/$library[.png].ts @@ -0,0 +1,44 @@ +import { createFileRoute } from '@tanstack/react-router' +import { generateOgPng } from '~/server/og/generate.server' + +const CACHE_HEADERS = { + 'Cache-Control': + 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800', +} as const + +export const Route = createFileRoute('/api/og/$library.png')({ + server: { + handlers: { + GET: async ({ + request, + params, + }: { + request: Request + params: { [key: string]: string } + }) => { + // TanStack Router encodes the param name from the filename segment + // "$library[.png]" as "library.png" (with the literal dot included). + // Grab the value under either key and strip any trailing ".png" suffix. + const rawParam = params['library.png'] ?? params['library'] ?? '' + const libraryId = rawParam.replace(/\.png$/, '') + + const url = new URL(request.url) + const title = url.searchParams.get('title') ?? undefined + const description = url.searchParams.get('description') ?? undefined + + const result = await generateOgPng({ libraryId, title, description }) + + if ('kind' in result) { + return new Response(`Unknown library: ${libraryId}`, { status: 404 }) + } + + return new Response(new Uint8Array(result), { + headers: { + 'Content-Type': 'image/png', + ...CACHE_HEADERS, + }, + }) + }, + }, + }, +}) diff --git a/src/server/og/assets.server.ts b/src/server/og/assets.server.ts new file mode 100644 index 000000000..80a86b803 --- /dev/null +++ b/src/server/og/assets.server.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +function readBinary(relPath: string): Buffer { + // Resolve from the project root. In Netlify functions the working directory + // is the function bundle root, which includes `public/` via the included_files + // config. Locally (dev + preview script) cwd is the repo root. + return readFileSync(resolve(process.cwd(), relPath)) +} + +let cached: { + interRegular: Buffer + interExtraBold: Buffer + islandDataUrl: string +} | null = null + +export function loadOgAssets() { + if (cached) return cached + + const interRegular = readBinary('public/fonts/Inter-Regular.ttf') + const interExtraBold = readBinary('public/fonts/Inter-ExtraBold.ttf') + const islandBytes = readBinary('public/images/logos/splash-dark.png') + const islandDataUrl = `data:image/png;base64,${islandBytes.toString('base64')}` + + cached = { interRegular, interExtraBold, islandDataUrl } + return cached +} diff --git a/src/server/og/colors.ts b/src/server/og/colors.ts new file mode 100644 index 000000000..147040fed --- /dev/null +++ b/src/server/og/colors.ts @@ -0,0 +1,30 @@ +import type { LibraryId } from '~/libraries' + +// Each entry matches the textStyle in `src/libraries/libraries.ts`. +// Values are Tailwind v4 default palette (500 shades) with an override +// for black/gray-100 libraries so they stay legible on the dark canvas. +const LIBRARY_ACCENT_COLORS: Record = { + query: '#fb2c36', // red-500 + router: '#00bc7d', // emerald-500 + start: '#00b8db', // cyan-500 + table: '#2b7fff', // blue-500 + form: '#f0b100', // yellow-500 + db: '#ff6900', // orange-500 + ai: '#f6339a', // pink-500 + intent: '#00a6f4', // sky-500 + virtual: '#ad46ff', // purple-500 + pacer: '#7ccf00', // lime-500 + hotkeys: '#ff2056', // rose-500 + store: '#ae7d44', // twine-500 (custom token in app.css) + ranger: '#f5f5f5', // gray-100 (black/gray-100 library) + config: '#f5f5f5', // gray-100 + devtools: '#f5f5f5', // gray-100 + cli: '#615fff', // indigo-500 + mcp: '#f5f5f5', // gray-100 (hidden library, fallback) +} + +const DEFAULT_ACCENT = '#f5f5f5' + +export function getAccentColor(libraryId: LibraryId | string): string { + return LIBRARY_ACCENT_COLORS[libraryId] ?? DEFAULT_ACCENT +} diff --git a/src/server/og/generate.server.ts b/src/server/og/generate.server.ts new file mode 100644 index 000000000..0f845bc08 --- /dev/null +++ b/src/server/og/generate.server.ts @@ -0,0 +1,79 @@ +import { Resvg } from '@resvg/resvg-js' +import satori from 'satori' +import { findLibrary } from '~/libraries' +import type { LibraryId } from '~/libraries' +import { loadOgAssets } from './assets.server' +import { getAccentColor } from './colors' +import { buildOgTree } from './template' + +const MAX_TITLE_LENGTH = 80 +const MAX_DESCRIPTION_LENGTH = 160 + +type GenerateInput = { + libraryId: LibraryId | string + title?: string + description?: string +} + +export type OgLibraryNotFoundError = { + kind: 'library-not-found' + libraryId: string +} + +export async function generateOgPng( + input: GenerateInput, +): Promise { + const library = findLibrary(input.libraryId) + if (!library) { + return { kind: 'library-not-found', libraryId: input.libraryId } + } + + const assets = loadOgAssets() + const accentColor = getAccentColor(library.id) + const libraryName = library.name + const pitch = clampText(library.tagline ?? '', MAX_DESCRIPTION_LENGTH) + const docTitle = input.title?.trim() + ? clampText(input.title.trim(), MAX_TITLE_LENGTH) + : undefined + const description = input.description?.trim() + ? clampText(input.description.trim(), MAX_DESCRIPTION_LENGTH) + : undefined + + const tree = buildOgTree({ + libraryName, + accentColor, + islandDataUrl: assets.islandDataUrl, + pitch, + docTitle, + description, + }) + + const svg = await satori(tree, { + width: 1200, + height: 630, + fonts: [ + { + name: 'Inter', + data: assets.interRegular, + weight: 700, + style: 'normal', + }, + { + name: 'Inter', + data: assets.interExtraBold, + weight: 800, + style: 'normal', + }, + ], + }) + + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: 1200 }, + }) + return resvg.render().asPng() +} + +function clampText(text: string, max: number): string { + if (text.length <= max) return text + return text.slice(0, max - 1).trimEnd() + 'โ€ฆ' +} diff --git a/src/server/og/template.tsx b/src/server/og/template.tsx new file mode 100644 index 000000000..c61849f76 --- /dev/null +++ b/src/server/og/template.tsx @@ -0,0 +1,140 @@ +import type { ReactElement } from 'react' + +type TemplateProps = { + libraryName: string + accentColor: string + islandDataUrl: string + pitch: string + docTitle?: string + description?: string +} + +const WIDTH = 1200 +const HEIGHT = 630 +const ISLAND_SIZE = Math.round(HEIGHT * 0.72) + +// "TanStack AI" โ†’ ["TanStack", "AI"] +// "TanStack Router" โ†’ ["TanStack", "Router"] +// "Create TS Router App" โ†’ ["Create TS Router", "App"] (fallback: last word) +function splitName(name: string): [string, string] { + const parts = name.split(' ') + if (parts.length < 2) return [name, ''] + const last = parts[parts.length - 1] + const first = parts.slice(0, -1).join(' ') + return [first, last] +} + +export function buildOgTree(props: TemplateProps): ReactElement { + const [titleLine1, titleLine2] = splitName(props.libraryName) + + return ( +
+ {/* Island */} + + + {/* Text column */} +
+
+ {titleLine1} + {titleLine2 ? ( + + {titleLine2} + + ) : null} +
+ {!props.docTitle && !props.description && props.pitch ? ( +
+ {props.pitch} +
+ ) : null} + {props.docTitle ? ( +
+ {props.docTitle} +
+ ) : null} + {props.description ? ( +
+ {props.description} +
+ ) : null} +
+
+ ) +} + +function hexToRgba(hex: string, alpha: number): string { + const clean = hex.replace('#', '') + const r = parseInt(clean.slice(0, 2), 16) + const g = parseInt(clean.slice(2, 4), 16) + const b = parseInt(clean.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} diff --git a/src/utils/og.ts b/src/utils/og.ts new file mode 100644 index 000000000..048e1f4a4 --- /dev/null +++ b/src/utils/og.ts @@ -0,0 +1,25 @@ +import type { LibraryId } from '~/libraries' +import { canonicalUrl } from './seo' + +type OgImageOptions = { + title?: string | null + description?: string | null +} + +/** + * Absolute URL for a package-themed OG image. + * Defaults to the library's name + tagline. Pass a title/description + * to override (used by docs pages). + */ +export function ogImageUrl( + libraryId: LibraryId, + options: OgImageOptions = {}, +): string { + const params = new URLSearchParams() + if (options.title) params.set('title', options.title) + if (options.description) params.set('description', options.description) + + const qs = params.toString() + const path = `/api/og/${libraryId}.png${qs ? `?${qs}` : ''}` + return canonicalUrl(path) +} diff --git a/tests/smoke.ts b/tests/smoke.ts index 93d07ef96..06e34d3de 100644 --- a/tests/smoke.ts +++ b/tests/smoke.ts @@ -29,6 +29,11 @@ type TestCase = { expectedContent: string[] } +type ImageTestCase = { + name: string + path: string +} + const tests: TestCase[] = [ { name: 'home page', @@ -52,6 +57,14 @@ const tests: TestCase[] = [ }, ] +const ogTests: ImageTestCase[] = [ + { name: 'OG image ยท library landing', path: '/api/og/query.png' }, + { + name: 'OG image ยท docs page', + path: '/api/og/ai.png?title=useQuery&description=Fetch%20data', + }, +] + async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = createServer() @@ -170,6 +183,46 @@ async function main() { console.log(`\n${passed} passed, ${failed} failed\n`) + // Test OG image endpoints + console.log('Running OG image tests...\n') + + for (const testCase of ogTests) { + const url = `${baseUrl}${testCase.path}` + try { + const response = await fetch(url) + + if (!response.ok) { + console.log(` โœ— ${testCase.name}: HTTP ${response.status}`) + failed++ + continue + } + + const contentType = response.headers.get('content-type') + if (contentType !== 'image/png') { + console.log(` โœ— ${testCase.name}: content-type ${contentType}`) + failed++ + continue + } + + const body = await response.arrayBuffer() + if (body.byteLength === 0) { + console.log(` โœ— ${testCase.name}: empty body`) + failed++ + continue + } + + console.log(` โœ“ ${testCase.name} (${body.byteLength} bytes)`) + passed++ + } catch (err) { + console.log( + ` โœ— ${testCase.name}: ${err instanceof Error ? err.message : String(err)}`, + ) + failed++ + } + } + + console.log(`\n${passed} passed, ${failed} failed\n`) + if (serverProcess) { process.kill(-serverProcess.pid!, 'SIGTERM') } diff --git a/vite.config.ts b/vite.config.ts index 4f2da0792..32f2780b2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,9 @@ const rscSsrExternals = [ 'pako', // These packages also have known CJS/ESM interop issues in the RSC/SSR path. 'discord-interactions', + // OG image generation: resvg-js ships a native .node binary that cannot + // be bundled by rolldown โ€” must be externalized for SSR environments. + '@resvg/resvg-js', ] const sentrySsrExternals = ['@sentry/node', '@sentry/tanstackstart-react'] @@ -100,6 +103,8 @@ export default defineConfig({ // CTA packages use execa which has a broken unicorn-magic dependency '@tanstack/create', 'discord-interactions', + // OG image generation: resvg-js ships a native .node binary + '@resvg/resvg-js', // Don't pre-bundle CLI so we always get fresh changes during dev ...(isDev ? ['@tanstack/cli'] : []), ],