diff --git a/.claude/skills/docs/references/seo.md b/.claude/skills/docs/references/seo.md index c2b68435..534023ab 100644 --- a/.claude/skills/docs/references/seo.md +++ b/.claude/skills/docs/references/seo.md @@ -94,6 +94,20 @@ about: { This helps search engines categorize docs as documentation for a specific software product. +## OG images + +Every supported page automatically gets a branded Open Graph image (1200×630 for OG, 1200×600 for Twitter) served by the dynamic Astro OG route via satori + resvg. The images show the Video.js mono logo, the page title in uppercase Eurostile, and the site's colour bars. The route only responds for known internal page paths, and Netlify caches each rendered image until the next deploy. + +**URLs** are derived from the page pathname: +- OG: `/og/{slug}.png` (e.g., `/og/blog/my-post.png`) +- Twitter: `/og/twitter/{slug}.png` + +**`ogTitle` frontmatter field** — use this when a page title is too long for a social preview. The OG image generator uppercases and renders this instead of the regular title. Titles over ~80 characters are truncated with an ellipsis and log a warning when the image is generated. + +**Manual image override** — set `ogImage` (blog only) or pass the `image` prop to `Base.astro` to bypass auto-generation entirely. + +Configuration constants (font sizes, thresholds) live at the top of `src/utils/og/render-og-image.tsx`. + ## Internal linking Use keyword-rich anchor text for internal links instead of generic text: diff --git a/.claude/skills/docs/templates/concept.md b/.claude/skills/docs/templates/concept.md index bcbf2ada..7c720e97 100644 --- a/.claude/skills/docs/templates/concept.md +++ b/.claude/skills/docs/templates/concept.md @@ -11,12 +11,13 @@ description: 'One-sentence summary for search and metadata' --- ``` -Optional — override the title per framework: +Optional fields: ```yaml --- title: 'State management' description: 'How Video.js manages player state' +ogTitle: 'State management' # Shorter title for OG image (if page title is long) frameworkTitle: html: 'State management in HTML' react: 'State management in React' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74e8548b..1fba1a19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,9 @@ importers: '@nanostores/react': specifier: ^1.0.0 version: 1.0.0(nanostores@1.1.1)(react@19.2.4) + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 '@sentry/astro': specifier: ^10.43.0 version: 10.47.0(astro@6.1.4(@netlify/blobs@10.7.0)(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.59.0)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.2))(rollup@4.59.0) @@ -537,6 +540,9 @@ importers: reading-time: specifier: ^1.5.0 version: 1.5.0 + satori: + specifier: ^0.26.0 + version: 0.26.0 schema-dts: specifier: ^1.1.5 version: 1.1.5 @@ -2378,6 +2384,86 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@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.9': resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2811,6 +2897,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 + '@simple-libs/stream-utils@1.2.0': resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} @@ -3678,6 +3769,10 @@ packages: bare-url@2.3.2: resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + 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==} @@ -3778,6 +3873,9 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} @@ -4019,9 +4117,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'} @@ -4277,6 +4392,10 @@ packages: emmet@2.4.11: resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4384,6 +4503,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@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -4533,6 +4655,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -4834,6 +4959,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==} @@ -5465,6 +5594,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==} @@ -6087,10 +6219,16 @@ 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==} + parent-module@1.0.1: 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==} @@ -6237,6 +6375,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.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'} @@ -6672,6 +6813,10 @@ packages: sass-formatter@0.7.9: resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==} + satori@0.26.0: + resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==} + engines: {node: '>=16'} + sax@1.2.1: resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} @@ -6871,6 +7016,9 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -7217,6 +7365,9 @@ packages: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} + 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'} @@ -7827,6 +7978,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'} @@ -9917,6 +10071,57 @@ snapshots: dependencies: quansync: 1.0.0 + '@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.9': optional: true @@ -10282,6 +10487,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 + '@simple-libs/stream-utils@1.2.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -11420,6 +11630,8 @@ snapshots: dependencies: bare-path: 3.0.0 + base64-js@0.0.8: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.10: {} @@ -11523,6 +11735,8 @@ snapshots: camelcase@6.3.0: {} + camelize@1.0.1: {} + caniuse-lite@1.0.30001781: {} ccount@2.0.1: {} @@ -11729,6 +11943,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 @@ -11737,6 +11959,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 @@ -12008,6 +12236,8 @@ snapshots: '@emmetio/abbreviation': 2.3.3 '@emmetio/css-abbreviation': 2.1.8 + emoji-regex-xs@2.0.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -12205,6 +12435,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} escodegen@2.1.0: @@ -12376,6 +12608,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.7.4: {} + fflate@0.8.2: {} figures@6.1.0: @@ -12774,6 +13008,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hex-rgb@4.3.0: {} + hls.js@1.6.15: {} hookable@6.1.0: {} @@ -13387,6 +13623,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: {} lint-staged@16.3.2: @@ -14257,10 +14498,17 @@ snapshots: package-manager-detector@1.6.0: {} + pako@0.2.9: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + 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 @@ -14392,6 +14640,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} + postcss-values-parser@6.0.2(postcss@8.5.8): dependencies: color-name: 1.1.4 @@ -14916,6 +15166,20 @@ snapshots: dependencies: suf-log: 2.5.3 + 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.2.1: {} sax@1.5.0: {} @@ -15164,6 +15428,8 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string.prototype.codepointat@0.2.1: {} + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -15514,6 +15780,11 @@ snapshots: undici@6.23.0: {} + 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: {} @@ -16138,6 +16409,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/site/CLAUDE.md b/site/CLAUDE.md index 4dda848d..cf762cca 100644 --- a/site/CLAUDE.md +++ b/site/CLAUDE.md @@ -449,7 +449,8 @@ All content must be written in **MDX format** to support: authors: string[]; // Reference to authors.json canonical?: string; // Canonical URL override devOnly?: boolean; // Show only in development - ogImage?: ImageMetadata | string; // Local image or external URL + ogTitle?: string; // Shorter title for the default dynamic OG image + ogImage?: ImageMetadata | string; // Local image or external URL (overrides the default OG route) twitterImage?: ImageMetadata | string; // Falls back to ogImage } ``` @@ -459,6 +460,8 @@ All content must be written in **MDX format** to support: ogImage: '../../assets/blog/2026-03-10-my-post/og.png' ``` +Without a manual override, `Base.astro` derives `/og/{slug}.png` and `/og/twitter/{slug}.png` automatically. Those images are rendered on demand by `src/pages/og/[...path].png.ts`, limited to known internal page paths, and cached by Netlify until the next deploy. + ### Docs Collection (`src/content/docs/`) **Subdirectories:** @@ -471,6 +474,7 @@ ogImage: '../../assets/blog/2026-03-10-my-post/og.png' { title: string; description: string; + ogTitle?: string; // Shorter title for the default dynamic OG image frameworkTitle?: { // Per-framework title overrides html?: string; react?: string; diff --git a/site/README.md b/site/README.md index 0e2fbc1b..c5e73cf0 100644 --- a/site/README.md +++ b/site/README.md @@ -156,6 +156,12 @@ const sidebar: Sidebar = [ ]; ``` +### Social previews + +By default, pages get branded OG/Twitter images at `/og/{slug}.png` and `/og/twitter/{slug}.png`. Those images are rendered on demand by [`src/pages/og/[...path].png.ts`](src/pages/og/[...path].png.ts), limited to known internal routes, and cached by Netlify until the next deploy. + +Use `ogTitle` in docs/blog frontmatter when the page title is too long for the social card. For fully custom art, use the existing manual image overrides (`ogImage`, `twitterImage`, or layout `image` props). + ### References API reference pages are generated from TypeScript source code by the builder in [`scripts/api-docs-builder/`](scripts/api-docs-builder/). It extracts props, state, data attributes, and part information, then outputs JSON to `src/content/generated-api-reference/` (gitignored). diff --git a/site/package.json b/site/package.json index 269d4cae..9e198c59 100644 --- a/site/package.json +++ b/site/package.json @@ -26,6 +26,7 @@ "@mux/mux-node": "^12.8.1", "@mux/mux-uploader-react": "^1.4.1", "@nanostores/react": "^1.0.0", + "@resvg/resvg-js": "^2.6.2", "@sentry/astro": "^10.43.0", "@shikijs/transformers": "^4.0.2", "@tailwindcss/vite": "^4.2.1", @@ -46,6 +47,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "reading-time": "^1.5.0", + "satori": "^0.26.0", "schema-dts": "^1.1.5", "sharp": "^0.34.3", "shiki": "^4.0.2", diff --git a/site/public/og-default.png b/site/public/og-default.png deleted file mode 100644 index 418c4cd2..00000000 Binary files a/site/public/og-default.png and /dev/null differ diff --git a/site/scripts/generate-og-default.mjs b/site/scripts/generate-og-default.mjs deleted file mode 100644 index d3087bfc..00000000 --- a/site/scripts/generate-og-default.mjs +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Generates the default OG image: Video.js mono logo in manila on a dark background. - * - * Usage: node scripts/generate-og-default.mjs - * Output: public/og-default.png (1200×630) - */ - -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import sharp from 'sharp'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const BACKGROUND = '#1e1d1d'; // faded-black (dark mode bg) -const LOGO_FILL = '#f3e7d2'; // manila-light -const WIDTH = 1200; -const HEIGHT = 630; - -// Read the mono logo SVG and replace currentColor with manila -const logoSvg = readFileSync(resolve(__dirname, '../src/assets/logos/videojs-mono.svg'), 'utf-8'); - -// The logo viewBox is 0 0 381 68 → aspect ratio ~5.6:1 -const logoNativeW = 381; -const logoNativeH = 68; -const logoAspect = logoNativeW / logoNativeH; - -// ~80% of image width -const logoW = Math.round(WIDTH * 0.8); -const logoH = Math.round(logoW / logoAspect); - -// Prepare the logo SVG at the target size with manila fill -const coloredLogo = logoSvg - .replace(/currentColor/g, LOGO_FILL) - .replace(/ - Video.js - - - - - - - - - - - - - - - + diff --git a/site/src/consts.ts b/site/src/consts.ts index 8d836072..691fe983 100644 --- a/site/src/consts.ts +++ b/site/src/consts.ts @@ -11,6 +11,7 @@ export const MUX_URL = 'https://www.mux.com?utm_source=videojs&utm_campaign=vjs1 export const MUX_SUPPORT_URL = 'https://www.mux.com/sales-contact?form=sales&utm_source=videojs&utm_campaign=vjs10'; export const THEME_KEY = 'vjs-site-theme'; export const BANNER_DISMISS_KEY = 'vjs-legacy-banner-dismissed'; +export const BLOG_PAGE_SIZE = 10; /** * Video source for demos and examples throughout the site, diff --git a/site/src/content.config.ts b/site/src/content.config.ts index 1728baf3..a5a87555 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -61,6 +61,7 @@ const blog = defineCollection({ authors: z.array(reference('authors')), canonical: z.url().optional(), devOnly: z.boolean().optional(), // only visible in development mode + ogTitle: z.string().optional(), ogImage: image().or(z.url()).optional(), twitterImage: image().or(z.url()).optional(), }), @@ -89,6 +90,7 @@ const docs = defineCollection({ title: z.string(), description: z.string(), updatedDate: z.coerce.date().optional(), + ogTitle: z.string().optional(), frameworkTitle: z.partialRecord(z.enum(SUPPORTED_FRAMEWORKS as [string, ...string[]]), z.string()).optional(), }), }); diff --git a/site/src/content/blog/2015-09-29-video-js-5-the-only-thing-that’s-changed-is-everything-except-for-like-3-things-that-didn-t-including-the-name.mdx b/site/src/content/blog/2015-09-29-video-js-5-the-only-thing-that’s-changed-is-everything-except-for-like-3-things-that-didn-t-including-the-name.mdx index bbb632d1..812fb3bc 100644 --- a/site/src/content/blog/2015-09-29-video-js-5-the-only-thing-that’s-changed-is-everything-except-for-like-3-things-that-didn-t-including-the-name.mdx +++ b/site/src/content/blog/2015-09-29-video-js-5-the-only-thing-that’s-changed-is-everything-except-for-like-3-things-that-didn-t-including-the-name.mdx @@ -1,5 +1,6 @@ --- title: "Video.js 5: The only thing that's changed is everything (except for like 3 things that didn't, including the name)" +ogTitle: "Video.js 5: The only thing that's changed is everything" description: Video.js 5.0 features a completely redesigned UI with flex-box controls, ES6 codebase compiled with Babel, switched from Less to Sass, and represents 146 merged pull requests from 25 contributors updating nearly every line of code. canonical: https://videojs.org/blog/video-js-5-the-only-thing-that’s-changed-is-everything-except-for-like-3-things-that-didn-t-including-the-name tags: diff --git a/site/src/content/docs/how-to/write-guides.mdx b/site/src/content/docs/how-to/write-guides.mdx index 26643c8b..5343beca 100644 --- a/site/src/content/docs/how-to/write-guides.mdx +++ b/site/src/content/docs/how-to/write-guides.mdx @@ -34,6 +34,20 @@ Our guides are [MDX](https://mdxjs.com) files placed in the matching directory: - Concept pages go in `src/content/docs/concepts/[slug].mdx` - How-to guides go in `src/content/docs/how-to/[slug].mdx` +Every guide needs a frontmatter block with `title` and `description`: + +```yaml +--- +title: 'Your guide title' +description: 'One-sentence summary for search and metadata' +--- +``` + +Optional frontmatter fields: + +- **`frameworkTitle`** — override the title for specific frameworks (e.g., `frameworkTitle: { react: 'Hooks in React' }`) +- **`ogTitle`** — shorter title for the default `/og/...` and `/og/twitter/...` social preview images. Use this when your page title is too long for the rendered OG card (over ~80 characters). + ## 3. Add that guide to the sidebar Next, open `src/docs.config.ts` and add your guide to the appropriate section of the sidebar. For example, to add a how-to guide on "Writing guides", you would add: diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index f53c05a1..8196fc36 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -31,8 +31,19 @@ function resolveImageUrl(img: ImageMetadata | string): string { return new URL(img.src, Astro.url).toString(); } -const defaultOgImage = new URL('/og-default.png', Astro.site).toString(); -const resolvedTwitterImage = twitterImage ?? image; +// Auto-derive OG image URLs from the page pathname. +// Each prop is independent — `twitterImage` can override the Twitter card +// without requiring `image` to also be set. +const slug = Astro.url.pathname.replace(/^\//, '').replace(/\/$/, '') || 'index'; +const autoOgImage = new URL(`/og/${slug}.png`, Astro.site).toString(); +const autoTwitterImage = new URL(`/og/twitter/${slug}.png`, Astro.site).toString(); + +const resolvedOgImage = image ? resolveImageUrl(image) : autoOgImage; +const resolvedTwitterImage = twitterImage + ? resolveImageUrl(twitterImage) + : image + ? resolveImageUrl(image) + : autoTwitterImage; const seoSuffix = suffix ?? SEO_SUFFIX; const pageTitle = Array.isArray(title) ? title.join(' | ') : title; @@ -119,10 +130,7 @@ const fullTitle = /> - + {/* Twitter */} @@ -132,12 +140,7 @@ const fullTitle = /> - + {/* Whatever else */} diff --git a/site/src/pages/blog/[...page].astro b/site/src/pages/blog/[...page].astro index 428660fa..65f6504c 100644 --- a/site/src/pages/blog/[...page].astro +++ b/site/src/pages/blog/[...page].astro @@ -7,7 +7,7 @@ import BlogPagination from '@/components/blog/BlogPagination.astro'; import BlogPostCard from '@/components/blog/BlogPostCard.astro'; import FooterEasterEgg from '@/components/FooterEasterEgg.astro'; import JsonLd from '@/components/JsonLd.astro'; -import { PRODUCTION_URL } from '@/consts'; +import { BLOG_PAGE_SIZE, PRODUCTION_URL } from '@/consts'; import Blog from '@/layouts/Blog.astro'; import { createBlogCollectionSchema } from '@/utils/jsonLd/schemas'; @@ -24,7 +24,7 @@ export const getStaticPaths = (async ({ paginate }) => { })) ); - return paginate(postsWithAuthors, { pageSize: 10 }); + return paginate(postsWithAuthors, { pageSize: BLOG_PAGE_SIZE }); }) satisfies GetStaticPaths; type PostWithAuthors = CollectionEntry<'blog'> & { diff --git a/site/src/pages/og/[...path].png.ts b/site/src/pages/og/[...path].png.ts new file mode 100644 index 00000000..5944d89b --- /dev/null +++ b/site/src/pages/og/[...path].png.ts @@ -0,0 +1,45 @@ +import type { APIRoute } from 'astro'; + +import { renderOgImage } from '@/utils/og/render-og-image'; +import { getOgCacheHeaders, resolveOgRequest } from '@/utils/og/resolve-og-request'; + +export const prerender = false; + +const imagePromiseCache = new Map>(); + +function getCachedOgImage(cacheKey: string, title: string, size: 'og' | 'twitter'): Promise { + let pngPromise = imagePromiseCache.get(cacheKey); + + if (!pngPromise) { + pngPromise = renderOgImage({ title, size }).catch((error) => { + imagePromiseCache.delete(cacheKey); + throw error; + }); + imagePromiseCache.set(cacheKey, pngPromise); + } + + return pngPromise; +} + +export const GET: APIRoute = async ({ params }) => { + // Dynamic mode intentionally uses a whitelist of known site paths instead of + // allowing arbitrary title or slug inputs. + const ogRequest = await resolveOgRequest(params.path); + + if (!ogRequest) { + return new Response('Not found', { + status: 404, + headers: { 'Cache-Control': 'public, max-age=0, must-revalidate' }, + }); + } + + const cacheKey = `${ogRequest.size}:${ogRequest.sitePath}`; + const png = await getCachedOgImage(cacheKey, ogRequest.title, ogRequest.size); + + return new Response(new Uint8Array(png), { + headers: { + 'Content-Type': 'image/png', + ...getOgCacheHeaders(), + }, + }); +}; diff --git a/site/src/utils/og/normalize-site-path.ts b/site/src/utils/og/normalize-site-path.ts new file mode 100644 index 00000000..d16b2c11 --- /dev/null +++ b/site/src/utils/og/normalize-site-path.ts @@ -0,0 +1,3 @@ +export function normalizeSitePath(path: string): string { + return path.replace(/^\/+|\/+$/g, '') || 'index'; +} diff --git a/site/src/utils/og/render-og-image.tsx b/site/src/utils/og/render-og-image.tsx new file mode 100644 index 00000000..d80754ed --- /dev/null +++ b/site/src/utils/og/render-og-image.tsx @@ -0,0 +1,201 @@ +import { Resvg } from '@resvg/resvg-js'; +import satori from 'satori'; + +// --------------------------------------------------------------------------- +// Configuration — tune these values to adjust OG image appearance +// --------------------------------------------------------------------------- + +/** Font size (px) for short titles. */ +export const LARGE_FONT_SIZE = 48; +/** Font size (px) for long titles. */ +export const SMALL_FONT_SIZE = 36; +/** Titles longer than this (in characters, after uppercasing) use the small font. */ +export const LARGE_SMALL_THRESHOLD = 25; +/** Titles longer than this (in characters, after uppercasing) are truncated with an ellipsis. */ +export const MAX_CHAR_LIMIT = 80; + +// --------------------------------------------------------------------------- +// Design tokens +// --------------------------------------------------------------------------- + +const BG_COLOR = '#1e1d1d'; // faded-black +const TEXT_COLOR = '#f3e7d2'; // manila-light +const LOGO_WIDTH = 800; +const LOGO_HEIGHT = Math.round(LOGO_WIDTH * (68 / 381)); // ≈143px +const LARGE_TITLE_GAP = 52; // px between logo and title for short titles +const SMALL_TITLE_GAP = 52; // px between logo and title for long titles +const H_PADDING = 100; // horizontal padding +const COLOR_BAR_HEIGHT = 118; + +const COLOR_BARS = [ + { color: '#ffa81b', flex: 80 }, // gold + { color: '#ff6200', flex: 60 }, // orange + { color: '#eb3132', flex: 45 }, // red + { color: '#cc3566', flex: 20 }, // magenta + { color: '#922e4f', flex: 10 }, // magenta-dark +] as const; + +const SIZES = { + og: { width: 1200, height: 630, topMargin: 30 }, + twitter: { width: 1200, height: 600, topMargin: 15 }, +} as const; + +// --------------------------------------------------------------------------- +// Font loading (fetched once, cached in module scope) +// Note: satori does not support woff2, so we use the woff variant. +// --------------------------------------------------------------------------- + +const FONT_URL = 'https://static.mux.com/fonts/EurostileLTProBoldExtended2/font.woff'; +const FONT_FAMILY = 'Eurostile LT Pro Bold Extended 2'; + +let fontDataPromise: Promise | null = null; + +async function fetchFont(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load font: ${response.status}`); + } + return response.arrayBuffer(); +} + +function loadFont(): Promise { + if (!fontDataPromise) { + fontDataPromise = fetchFont(FONT_URL).catch((err) => { + fontDataPromise = null; // allow retry on next call + throw new Error( + `Failed to load OG image font from ${FONT_URL}: ${err.message}. ` + + 'Ensure the build environment can reach static.mux.com.' + ); + }); + } + return fontDataPromise; +} + +// --------------------------------------------------------------------------- +// Inlined Video.js mono logo (SVGO-optimised, from src/assets/logos/videojs-mono.svg) +// --------------------------------------------------------------------------- + +const LOGO_PATH = + 'M88.705 0v14.958H71.204V0zm0 18.912v47.32H71.204v-47.32zM48.643 0v52.106L20.918 0H0l35.263 66.257h30.881V0zm101.694 0v66.257h-32.681c-13.328 0-23.917-13.475-23.917-25.754 0-13.969 9.755-25.519 23.578-25.519h11.502v14.958h-5.659c-6.078 0-11.085 4.812-11.085 10.718s4.929 10.64 11.006 10.64h9.755V0zm62.023 66.257h-29.577c-14.997 0-28.091-9.729-28.091-25.857 0-13.137 12.415-25.442 28.169-25.442h29.499v14.958l-11.659 17.039h-19.822l12.337-17.04h-8.999c-6.677 0-11.163 4.32-11.163 10.302 0 6.4 5.008 11.056 11.163 11.056h28.169v14.958zM245.069 68c-15.832 0-30.072-10.9-30.072-27.341 0-16.44 14.658-27.419 30.177-27.419s29.994 11.056 29.994 27.419-14.345 27.34-30.073 27.34zm-11.763-27.367c0 7.31 5.086 12.382 11.842 12.382 6.755 0 11.658-5.072 11.658-12.382s-4.825-12.46-11.58-12.46-11.92 5.306-11.92 12.46m124.57-15.503h-10.668c-2.921 0-5.268-1.405-5.268-4.293s2.269-4.032 5.112-4.084h29.134V0H346.4c-11.216 0-22.822 7.96-22.822 20.707s11.606 20.239 22.822 20.239h10.667c2.974 0 5.373 1.352 5.373 4.318s-2.425 4.266-5.373 4.266h-33.124v16.727h33.959c11.267 0 22.926-7.18 22.926-20.993 0-12.85-11.659-20.16-22.926-20.16zm-18.023 0h-12.337c-.365-1.38-.6-2.836-.6-4.423 0-1.405.183-2.706.496-3.954h12.337c-.783 1.145-1.226 2.523-1.226 4.084 0 1.639.495 3.096 1.33 4.292m6.521-21.748h26.395v9.963h-44.027c3.312-6.295 10.668-9.99 17.632-9.99zm-17.475 25.13h28.977c6.912 0 14.11 3.147 17.527 9.052h-29.029c-7.043 0-14.137-3.096-17.475-9.079zm48.512 16.778c0 1.509-.182 2.94-.495 4.266h-12.415c.834-1.17 1.304-2.627 1.304-4.266s-.47-3.122-1.304-4.318h12.311c.365 1.352.599 2.783.599 4.318M357.876 62.9h-30.568v-9.963h48.408c-3.234 6.555-10.563 9.963-17.84 9.963m-57.329-26.325c0 3.486-1.852 12.93-12.154 12.955h-11.294v16.753l13.485.13h3.338c15.415-.026 24.596-9.937 24.596-27.522V0h-17.971zm2.921 4.37h11.607c-.157 3.2-.626 6.062-1.435 8.611h-15.545c2.478-1.899 4.513-4.708 5.399-8.61zm.47-12.434h11.189v9.053h-11.241c0-.338.052-.65.052-1.015v-8.064zm0-3.382v-8.376h11.189v8.376zm-10.042 37.876h-3.26l-10.12-.078V52.912h31.716c-3.365 6.556-9.598 10.067-18.336 10.067zm21.231-59.623v9.99h-11.189v-9.99z'; + +function VideoJSLogo() { + return ( + + + + ); +} + +function ColorBars() { + return ( +
+ {COLOR_BARS.map(({ color, flex }) => ( +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export type OgSize = 'og' | 'twitter'; + +export async function renderOgImage(options: { title?: string; size: OgSize }): Promise { + const { title, size } = options; + const { width, height, topMargin } = SIZES[size]; + const fontData = await loadFont(); + + // Uppercase, then truncate if needed + let displayTitle = title?.toUpperCase(); + if (displayTitle && displayTitle.length > MAX_CHAR_LIMIT) { + const truncated = displayTitle.slice(0, MAX_CHAR_LIMIT); + const lastSpace = truncated.lastIndexOf(' '); + displayTitle = `${lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated}…`; + console.warn( + `⚠ OG image for "${title}": title truncated (${title!.length} chars > ${MAX_CHAR_LIMIT} max). Consider adding ogTitle to frontmatter.` + ); + } + + const fontSize = displayTitle && displayTitle.length > LARGE_SMALL_THRESHOLD ? SMALL_FONT_SIZE : LARGE_FONT_SIZE; + const titleGap = fontSize === LARGE_FONT_SIZE ? LARGE_TITLE_GAP : SMALL_TITLE_GAP; + const textWrap = fontSize === LARGE_FONT_SIZE ? 'pretty' : 'balance'; // balance messes up text when single-line + + const svg = await satori( +
+ {/* Content area: logo + title, optically centred above colour bars */} +
+ + {displayTitle && ( +
+ {displayTitle} +
+ )} +
+ +
, + { + width, + height, + fonts: [ + { + name: FONT_FAMILY, + data: fontData, + weight: 700, + style: 'normal' as const, + }, + ], + } + ); + + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: width }, + }); + const pngData = resvg.render(); + return Buffer.from(pngData.asPng()); +} diff --git a/site/src/utils/og/resolve-og-request.ts b/site/src/utils/og/resolve-og-request.ts new file mode 100644 index 00000000..23a108de --- /dev/null +++ b/site/src/utils/og/resolve-og-request.ts @@ -0,0 +1,95 @@ +import { normalizeSitePath } from '@/utils/og/normalize-site-path'; +import type { OgSize } from '@/utils/og/render-og-image'; +import { buildOgTitleMap } from '@/utils/og/title-entries'; + +const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365; + +type OgTitleMap = Map; + +export interface ResolvedOgRequest { + size: OgSize; + sitePath: string; + title: string; +} + +let ogTitleMapPromise: Promise | null = null; + +async function getOgTitleMap(): Promise { + if (!ogTitleMapPromise) { + ogTitleMapPromise = buildOgTitleMap().catch((error) => { + ogTitleMapPromise = null; + throw error; + }); + } + + return ogTitleMapPromise; +} + +function decodePathSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function parseOgRequestPath(pathParam: string | undefined): { size: OgSize; sitePath: string } | null { + if (!pathParam) { + return null; + } + + const rawSegments = pathParam + .replace(/\.png$/i, '') + .split('/') + .filter(Boolean) + .map(decodePathSegment); + + if (rawSegments.some((segment) => segment === '.' || segment === '..')) { + return null; + } + + const size: OgSize = rawSegments[0] === 'twitter' ? 'twitter' : 'og'; + const siteSegments = size === 'twitter' ? rawSegments.slice(1) : rawSegments; + + if (siteSegments.length === 0) { + return null; + } + + return { + size, + sitePath: normalizeSitePath(siteSegments.join('/')), + }; +} + +export async function resolveOgRequest(pathParam: string | undefined): Promise { + const parsedRequest = parseOgRequestPath(pathParam); + + if (!parsedRequest) { + return null; + } + + const titleMap = await getOgTitleMap(); + const title = titleMap.get(parsedRequest.sitePath); + + if (!title) { + return null; + } + + return { + ...parsedRequest, + title, + }; +} + +export function getOgCacheHeaders(): Record { + return { + 'Cache-Control': 'public, max-age=0, must-revalidate', + 'CDN-Cache-Control': `public, max-age=${ONE_YEAR_IN_SECONDS}`, + // `durable` stores responses in Netlify's shared global cache (not just per-edge). + // Netlify purges all cached responses — including durable — on every deploy + // (atomic deploys). Opting out would require `Netlify-Cache-ID`, which we + // intentionally omit so each deploy gets fresh images. + // Ref: https://docs.netlify.com/build/caching/caching-overview/ + 'Netlify-CDN-Cache-Control': `public, durable, max-age=${ONE_YEAR_IN_SECONDS}`, + }; +} diff --git a/site/src/utils/og/title-entries.ts b/site/src/utils/og/title-entries.ts new file mode 100644 index 00000000..c6954700 --- /dev/null +++ b/site/src/utils/og/title-entries.ts @@ -0,0 +1,119 @@ +import { getCollection } from 'astro:content'; + +import { BLOG_PAGE_SIZE } from '@/consts'; +import { SUPPORTED_FRAMEWORKS, type SupportedFramework } from '@/types/docs'; +import { filterSidebar, getAllGuideSlugs } from '@/utils/docs/sidebar'; +import { getDocTitle } from '@/utils/docs/title'; +import { normalizeSitePath } from '@/utils/og/normalize-site-path'; + +const STATIC_PAGES: { path: string; title: string }[] = [ + { path: 'index', title: 'The open source player for the web' }, + { path: 'support', title: 'Support' }, + { path: 'privacy', title: 'Privacy' }, + { path: 'blog', title: 'Blog' }, +]; + +export type OgTitleEntryKind = 'static' | 'blog' | 'blog-index' | 'author' | 'docs'; +export type OgTitleSource = 'static' | 'title' | 'ogTitle' | 'frameworkTitle' | 'name'; + +export interface OgTitleEntry { + kind: OgTitleEntryKind; + path: string; + title: string; + source: OgTitleSource; + collectionId?: string; + framework?: SupportedFramework; +} + +export async function listOgTitleEntries(): Promise { + const entries: OgTitleEntry[] = []; + + for (const { path, title } of STATIC_PAGES) { + entries.push({ + kind: path === 'blog' ? 'blog-index' : 'static', + path, + title, + source: 'static', + }); + } + + const blogPosts = (await getCollection('blog')).filter((post) => !post.data.devOnly || import.meta.env.DEV); + + for (const post of blogPosts) { + entries.push({ + kind: 'blog', + path: `blog/${post.id}`, + title: post.data.ogTitle ?? post.data.title, + source: post.data.ogTitle ? 'ogTitle' : 'title', + collectionId: post.id, + }); + } + + const totalBlogPages = Math.ceil(blogPosts.length / BLOG_PAGE_SIZE); + + for (let page = 2; page <= totalBlogPages; page += 1) { + entries.push({ + kind: 'blog-index', + path: `blog/${page}`, + title: 'Blog', + source: 'static', + }); + } + + const authors = await getCollection('authors'); + + for (const author of authors) { + entries.push({ + kind: 'author', + path: `blog/authors/${author.id}`, + title: author.data.name, + source: 'name', + collectionId: author.id, + }); + } + + const docsCollection = await getCollection('docs'); + + for (const framework of SUPPORTED_FRAMEWORKS) { + const allowedSlugs = new Set(getAllGuideSlugs(filterSidebar(framework))); + + for (const doc of docsCollection) { + if (!allowedSlugs.has(doc.id)) { + continue; + } + + const frameworkTitle = doc.data.frameworkTitle?.[framework]; + + entries.push({ + kind: 'docs', + path: `docs/framework/${framework}/${doc.id}`, + title: doc.data.ogTitle ?? getDocTitle(doc, framework), + source: doc.data.ogTitle ? 'ogTitle' : frameworkTitle ? 'frameworkTitle' : 'title', + collectionId: doc.id, + framework, + }); + } + } + + return entries; +} + +export async function buildOgTitleMap(): Promise> { + const titleMap = new Map(); + + for (const entry of await listOgTitleEntries()) { + const normalizedPath = normalizeSitePath(entry.path); + const existingTitle = titleMap.get(normalizedPath); + + if (existingTitle && existingTitle !== entry.title) { + throw new Error( + `Duplicate OG image path "${normalizedPath}" maps to multiple titles: ` + + `"${existingTitle}" and "${entry.title}".` + ); + } + + titleMap.set(normalizedPath, entry.title); + } + + return titleMap; +}