From 961766d81d1ca7fabfbdff4c802a6956e2bc9f0d Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Wed, 15 Apr 2026 11:49:35 -0500 Subject: [PATCH] feat(site): serve branded OG images dynamically (#1345) Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/skills/docs/references/seo.md | 14 + .claude/skills/docs/templates/concept.md | 3 +- pnpm-lock.yaml | 273 ++++++++++++++++++ site/CLAUDE.md | 6 +- site/README.md | 6 + site/package.json | 2 + site/public/og-default.png | Bin 33364 -> 0 bytes site/scripts/generate-og-default.mjs | 62 ---- site/src/assets/logos/videojs-mono.svg | 18 +- site/src/consts.ts | 1 + site/src/content.config.ts | 2 + ...-things-that-didn-t-including-the-name.mdx | 1 + site/src/content/docs/how-to/write-guides.mdx | 14 + site/src/layouts/Base.astro | 27 +- site/src/pages/blog/[...page].astro | 4 +- site/src/pages/og/[...path].png.ts | 45 +++ site/src/utils/og/normalize-site-path.ts | 3 + site/src/utils/og/render-og-image.tsx | 201 +++++++++++++ site/src/utils/og/resolve-og-request.ts | 95 ++++++ site/src/utils/og/title-entries.ts | 119 ++++++++ 20 files changed, 801 insertions(+), 95 deletions(-) delete mode 100644 site/public/og-default.png delete mode 100644 site/scripts/generate-og-default.mjs create mode 100644 site/src/pages/og/[...path].png.ts create mode 100644 site/src/utils/og/normalize-site-path.ts create mode 100644 site/src/utils/og/render-og-image.tsx create mode 100644 site/src/utils/og/resolve-og-request.ts create mode 100644 site/src/utils/og/title-entries.ts 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 418c4cd2bf8d810c107d661dc3081e63600c2955..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33364 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 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; +}