feat(site): serve branded OG images dynamically (#1345)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-04-15 11:49:35 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 521774a95b
commit 961766d81d
20 changed files with 801 additions and 95 deletions
+14
View File
@@ -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:
+2 -1
View File
@@ -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'
+273
View File
@@ -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
+5 -1
View File
@@ -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;
+6
View File
@@ -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).
+2
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

-62
View File
@@ -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(/<svg /, `<svg width="${logoW}" height="${logoH}" `);
// Optical centering: nudge the logo 10 px left of mathematical centre.
const nudgeLeft = 10;
const centreLeft = Math.round((WIDTH - logoW) / 2);
const centreTop = Math.round((HEIGHT - logoH) / 2);
const image = sharp({
create: {
width: WIDTH,
height: HEIGHT,
channels: 4,
background: BACKGROUND,
},
})
.composite([
{
input: Buffer.from(coloredLogo),
left: centreLeft - nudgeLeft,
top: centreTop,
},
])
.png();
const outputPath = resolve(__dirname, '../public/og-default.png');
await image.toFile(outputPath);
console.log(`Generated ${outputPath} (${WIDTH}×${HEIGHT})`);
+1 -17
View File
@@ -1,17 +1 @@
<svg viewBox="0 0 381 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Video.js</title>
<g clip-path="url(#clip0_549_3634)">
<path d="M88.7051 0V14.9579H71.204V0H88.7051ZM88.7051 18.912V66.2311H71.204V18.912H88.7051Z" fill="currentColor" />
<path d="M48.6432 0V52.1057L20.9179 0H0L35.2631 66.2572H48.6432H56.181H66.1444V0H48.6432Z" fill="currentColor" />
<path d="M150.337 0V66.2572H117.656C104.328 66.2572 93.7389 52.782 93.7389 40.5035C93.7389 26.5341 103.494 14.984 117.317 14.984H128.819V29.9419H123.16C117.082 29.9419 112.075 34.7544 112.075 40.6596C112.075 46.5647 117.004 51.2992 123.081 51.2992H132.836V0H150.337Z" fill="currentColor" />
<path d="M212.36 66.2572H182.783C167.786 66.2572 154.692 56.5281 154.692 40.3995C154.692 27.2625 167.107 14.958 182.861 14.958H212.36V29.9159L200.701 46.955H180.879L193.216 29.9159H184.217C177.54 29.9159 173.054 34.2342 173.054 40.2174C173.054 46.6168 178.062 51.2733 184.217 51.2733H212.386V66.2312L212.36 66.2572Z" fill="currentColor" />
<path d="M245.069 67.9993C229.237 67.9993 214.997 57.0995 214.997 40.6588C214.997 24.2181 229.655 13.2402 245.174 13.2402C260.693 13.2402 275.168 24.2961 275.168 40.6588C275.168 57.0215 260.823 67.9993 245.095 67.9993H245.069ZM233.306 40.6328C233.306 47.9426 238.392 53.0153 245.148 53.0153C251.903 53.0153 256.806 47.9426 256.806 40.6328C256.806 33.3229 251.981 28.1722 245.226 28.1722C238.471 28.1722 233.306 33.479 233.306 40.6328Z" fill="currentColor" />
<path d="M357.876 25.1293H347.208C344.287 25.1293 341.94 23.7246 341.94 20.837C341.94 17.9495 344.209 16.8049 347.052 16.7529H348.304H376.186V0H346.4C335.184 0 323.578 7.96022 323.578 20.707C323.578 33.4537 335.184 40.9457 346.4 40.9457H357.067C360.041 40.9457 362.44 42.2984 362.44 45.264C362.44 48.2295 360.015 49.5302 357.067 49.5302H323.943V66.2571H357.902C369.169 66.2571 380.828 59.0773 380.828 45.264C380.828 32.4132 369.169 25.1033 357.902 25.1033L357.876 25.1293ZM339.853 25.1293H327.516C327.151 23.7506 326.916 22.2938 326.916 20.707C326.916 19.3022 327.099 18.0015 327.412 16.7529H339.749C338.966 17.8975 338.523 19.2762 338.523 20.837C338.523 22.4759 339.018 23.9327 339.853 25.1293ZM346.374 3.38179H372.769V13.3451H347.208C347.208 13.3451 347.13 13.3451 347.078 13.3451H328.742C332.054 7.04973 339.41 3.35578 346.374 3.35578V3.38179ZM328.899 28.5111H348.304H357.876C364.788 28.5111 371.986 31.6588 375.403 37.5639H356.259H346.374C339.331 37.5639 332.237 34.4683 328.899 28.4851V28.5111ZM377.411 45.29C377.411 46.7988 377.229 48.2295 376.916 49.5562H364.501C365.335 48.3856 365.805 46.9288 365.805 45.29C365.805 43.6511 365.335 42.1683 364.501 40.9717H376.812C377.177 42.3244 377.411 43.7552 377.411 45.29ZM357.876 62.9013H327.308V52.938H356.259H375.716C372.482 59.4935 365.153 62.9013 357.876 62.9013Z" fill="currentColor" />
<path d="M300.547 36.5754C300.547 40.0612 298.695 49.5042 288.393 49.5302H277.099V66.2831L290.584 66.4132H293.922C309.337 66.3872 318.518 56.4759 318.518 38.8906V0H300.547V36.5754ZM303.468 40.9457H315.075C314.918 44.1454 314.449 47.0069 313.64 49.5562H298.095C300.573 47.6572 302.608 44.8477 303.494 40.9457H303.468ZM303.938 28.5111H315.127V37.5639H303.886C303.886 37.2257 303.938 36.9135 303.938 36.5494V28.4851V28.5111ZM303.938 25.1293V16.7529H315.127V25.1293H303.938ZM293.896 63.0054H290.636L280.516 62.9273V52.912H288.419H312.232C308.867 59.4675 302.634 62.9793 293.896 62.9793V63.0054ZM315.127 3.38179V13.3711H303.938V3.38179H315.127Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_549_3634">
<rect width="380.8" height="68" fill="white" />
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 381 68"><path fill="currentColor" d="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"/></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -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,
+2
View File
@@ -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(),
}),
});
@@ -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-thats-changed-is-everything-except-for-like-3-things-that-didn-t-including-the-name
tags:
@@ -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:
+15 -12
View File
@@ -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 =
/>
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta
property="og:image"
content={image ? resolveImageUrl(image) : defaultOgImage}
/>
<meta property="og:image" content={resolvedOgImage} />
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
@@ -132,12 +140,7 @@ const fullTitle =
/>
<meta property="twitter:title" content={fullTitle} />
<meta property="twitter:description" content={description} />
<meta
property="twitter:image"
content={resolvedTwitterImage
? resolveImageUrl(resolvedTwitterImage)
: defaultOgImage}
/>
<meta property="twitter:image" content={resolvedTwitterImage} />
{/* Whatever else */}
<slot name="head" />
+2 -2
View File
@@ -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'> & {
+45
View File
@@ -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<string, Promise<Buffer>>();
function getCachedOgImage(cacheKey: string, title: string, size: 'og' | 'twitter'): Promise<Buffer> {
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(),
},
});
};
+3
View File
@@ -0,0 +1,3 @@
export function normalizeSitePath(path: string): string {
return path.replace(/^\/+|\/+$/g, '') || 'index';
}
+201
View File
@@ -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<ArrayBuffer> | null = null;
async function fetchFont(url: string): Promise<ArrayBuffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load font: ${response.status}`);
}
return response.arrayBuffer();
}
function loadFont(): Promise<ArrayBuffer> {
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 (
<svg
viewBox="0 0 381 68"
width={LOGO_WIDTH}
height={LOGO_HEIGHT}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Video.js"
>
<path d={LOGO_PATH} fill={TEXT_COLOR} />
</svg>
);
}
function ColorBars() {
return (
<div
style={{
height: COLOR_BAR_HEIGHT,
display: 'flex',
flexDirection: 'column',
width: '100%',
}}
>
{COLOR_BARS.map(({ color, flex }) => (
<div key={color} style={{ flex, backgroundColor: color }} />
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export type OgSize = 'og' | 'twitter';
export async function renderOgImage(options: { title?: string; size: OgSize }): Promise<Buffer> {
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(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: BG_COLOR,
}}
>
{/* Content area: logo + title, optically centred above colour bars */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
paddingTop: topMargin,
paddingLeft: H_PADDING,
paddingRight: H_PADDING,
}}
>
<VideoJSLogo />
{displayTitle && (
<div
style={{
marginTop: titleGap,
fontFamily: FONT_FAMILY,
fontSize,
letterSpacing: '-0.03em',
lineHeight: 1.2,
color: TEXT_COLOR,
textAlign: 'center',
textWrap,
}}
>
{displayTitle}
</div>
)}
</div>
<ColorBars />
</div>,
{
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());
}
+95
View File
@@ -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<string, string>;
export interface ResolvedOgRequest {
size: OgSize;
sitePath: string;
title: string;
}
let ogTitleMapPromise: Promise<OgTitleMap> | null = null;
async function getOgTitleMap(): Promise<OgTitleMap> {
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<ResolvedOgRequest | null> {
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<string, string> {
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}`,
};
}
+119
View File
@@ -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<OgTitleEntry[]> {
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<Map<string, string>> {
const titleMap = new Map<string, string>();
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;
}