mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.6
parent
521774a95b
commit
961766d81d
@@ -0,0 +1,3 @@
|
||||
export function normalizeSitePath(path: string): string {
|
||||
return path.replace(/^\/+|\/+$/g, '') || 'index';
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user