Bladeren bron

web: Dynamic OpenGraph images (#8773)

Co-authored-by: Roo Code <[email protected]>
Bruno Bergher 2 maanden geleden
bovenliggende
commit
c74e42bde4

+ 1 - 0
apps/web-roo-code/package.json

@@ -17,6 +17,7 @@
 		"@roo-code/evals": "workspace:^",
 		"@roo-code/types": "workspace:^",
 		"@tanstack/react-query": "^5.79.0",
+		"@vercel/og": "^0.6.2",
 		"class-variance-authority": "^0.7.1",
 		"clsx": "^2.1.1",
 		"embla-carousel-auto-scroll": "^8.6.0",

BIN
apps/web-roo-code/public/og/base_a.png


BIN
apps/web-roo-code/public/og/base_b.png


+ 165 - 0
apps/web-roo-code/src/app/api/og/route.tsx

@@ -0,0 +1,165 @@
+import { ImageResponse } from "next/og"
+import { NextRequest } from "next/server"
+
+export const runtime = "edge"
+
+async function fetchWithTimeout(url: string, init?: RequestInit, timeoutMs = 3000) {
+	const controller = new AbortController()
+	const id = setTimeout(() => controller.abort(), timeoutMs)
+	try {
+		return await fetch(url, { ...init, signal: controller.signal })
+	} finally {
+		clearTimeout(id)
+	}
+}
+
+async function loadGoogleFont(font: string, text: string): Promise<ArrayBuffer | null> {
+	try {
+		const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`
+		const cssRes = await fetchWithTimeout(url)
+		if (!cssRes.ok) return null
+		const css = await cssRes.text()
+
+		const match =
+			css.match(/src:\s*url\(([^)]+)\)\s*format\('(?:woff2|woff|opentype|truetype)'\)/i) ||
+			css.match(/url\(([^)]+)\)/i)
+
+		const fontUrl = match && match[1] ? match[1].replace(/^['"]|['"]$/g, "") : null
+		if (!fontUrl) return null
+
+		const res = await fetchWithTimeout(fontUrl, undefined, 5000)
+		if (!res.ok) return null
+		return await res.arrayBuffer()
+	} catch {
+		return null
+	}
+}
+
+export async function GET(request: NextRequest) {
+	const requestUrl = new URL(request.url)
+	const { searchParams } = requestUrl
+
+	// Get title and description from query params
+	const title = searchParams.get("title") || "Roo Code"
+	const description = searchParams.get("description") || ""
+
+	// Combine all text that will be displayed for font loading
+	const displayText = title + description
+
+	// Check if we should try to use the background image
+	const useBackgroundImage = searchParams.get("bg") !== "false"
+
+	// Dynamically get the base URL from the current request
+	// This ensures it works correctly in development, preview, and production environments
+	const baseUrl = `${requestUrl.protocol}//${requestUrl.host}`
+	const variant = title.length % 2 === 0 ? "a" : "b"
+	const backgroundUrl = `${baseUrl}/og/base_${variant}.png`
+
+	// Preload fonts with graceful fallbacks
+	const regularFont = await loadGoogleFont("Inter", displayText)
+	const boldFont = await loadGoogleFont("Inter:wght@700", displayText)
+	const fonts: { name: string; data: ArrayBuffer; style?: "normal" | "italic"; weight?: 400 | 700 }[] = []
+	if (regularFont) {
+		fonts.push({ name: "Inter", data: regularFont, style: "normal", weight: 400 })
+	}
+	if (boldFont) {
+		fonts.push({ name: "Inter", data: boldFont, style: "normal", weight: 700 })
+	}
+
+	return new ImageResponse(
+		(
+			<div
+				style={{
+					width: "100%",
+					height: "100%",
+					display: "flex",
+					position: "relative",
+					// Use gradient background as default/fallback
+					background: "linear-gradient(135deg, #1e3a5f 0%, #0f1922 50%, #1a2332 100%)",
+				}}>
+				{/* Optional Background Image - only render if explicitly requested */}
+				{useBackgroundImage && (
+					<div
+						style={{
+							position: "absolute",
+							top: 0,
+							left: 0,
+							width: "100%",
+							height: "100%",
+							display: "flex",
+						}}>
+						{/* eslint-disable-next-line @next/next/no-img-element */}
+						<img
+							src={backgroundUrl}
+							alt=""
+							width={1200}
+							height={630}
+							style={{
+								width: "100%",
+								height: "100%",
+								objectFit: "cover",
+							}}
+						/>
+					</div>
+				)}
+
+				{/* Text Content */}
+				<div
+					style={{
+						position: "absolute",
+						display: "flex",
+						flexDirection: "column",
+						justifyContent: "flex-end",
+						top: "220px",
+						left: "80px",
+						right: "80px",
+						bottom: "80px",
+					}}>
+					{/* Main Title */}
+					<h1
+						style={{
+							fontSize: 70,
+							fontWeight: 700,
+							fontFamily: "Inter, Helvetica Neue, Helvetica, sans-serif",
+							color: "white",
+							lineHeight: 1.2,
+							margin: 0,
+							maxHeight: "2.4em",
+							overflow: "hidden",
+						}}>
+						{title}
+					</h1>
+
+					{/* Secondary Description */}
+					{description && (
+						<h2
+							style={{
+								fontSize: 70,
+								fontWeight: 400,
+								fontFamily: "Inter, Helvetica Neue, Helvetica, Arial, sans-serif",
+								color: "rgba(255, 255, 255, 0.9)",
+								lineHeight: 1.2,
+								margin: 0,
+								maxHeight: "2.4em",
+								overflow: "hidden",
+							}}>
+							{description}
+						</h2>
+					)}
+				</div>
+			</div>
+		),
+		{
+			width: 1200,
+			height: 630,
+			fonts: fonts.length ? fonts : undefined,
+			// Cache for 7 days in production, 3 seconds in development
+			headers: {
+				"Cache-Control":
+					process.env.NODE_ENV === "production"
+						? "public, max-age=604800, s-maxage=604800, stale-while-revalidate=86400"
+						: "public, max-age=3, s-maxage=3",
+			},
+		},
+	)
+}

+ 7 - 6
apps/web-roo-code/src/app/cloud/page.tsx

@@ -16,14 +16,15 @@ import type { Metadata } from "next"
 import { Button } from "@/components/ui"
 import { AnimatedBackground } from "@/components/homepage"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 import { EXTERNAL_LINKS } from "@/lib/constants"
 import Image from "next/image"
 
 const TITLE = "Roo Code Cloud"
 const DESCRIPTION =
 	"Roo Code Cloud gives you and your team the tools to take AI-coding to the next level with cloud agents, remote control, and more."
+const OG_DESCRIPTION = "Go way beyond the IDE"
 const PATH = "/cloud"
-const OG_IMAGE = SEO.ogImage
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -38,10 +39,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -51,7 +52,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [...SEO.keywords, "cloud", "subscription", "cloud agents", "AI cloud development"],
 }

+ 8 - 7
apps/web-roo-code/src/app/enterprise/page.tsx

@@ -7,12 +7,13 @@ import { ContactForm } from "@/components/enterprise/contact-form"
 import { EXTERNAL_LINKS } from "@/lib/constants"
 import type { Metadata } from "next"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 
-const TITLE = "Enterprise Solution"
+const TITLE = "Roo Code Cloud Enterprise"
 const DESCRIPTION =
 	"The control-plane for AI-powered software development. Gain visibility, governance, and control over your AI coding initiatives."
+const OG_DESCRIPTION = "The control-plane for AI-powered software development"
 const PATH = "/enterprise"
-const OG_IMAGE = SEO.ogImage
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -27,10 +28,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -40,7 +41,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [
 		...SEO.keywords,

+ 11 - 8
apps/web-roo-code/src/app/evals/page.tsx

@@ -2,6 +2,7 @@ import type { Metadata } from "next"
 
 import { getEvalRuns } from "@/actions/evals"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 
 import { Evals } from "./evals"
 
@@ -10,13 +11,8 @@ export const dynamic = "force-dynamic"
 
 const TITLE = "Evals"
 const DESCRIPTION = "Explore quantitative evals of LLM coding skills across tasks and providers."
+const OG_DESCRIPTION = "Quantitative evals of LLM coding skills"
 const PATH = "/evals"
-const IMAGE = {
-	url: "https://i.imgur.com/ijP7aZm.png",
-	width: 1954,
-	height: 1088,
-	alt: "Roo Code Evals – LLM coding benchmarks",
-}
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -29,7 +25,14 @@ export const metadata: Metadata = {
 		description: DESCRIPTION,
 		url: `${SEO.url}${PATH}`,
 		siteName: SEO.name,
-		images: [IMAGE],
+		images: [
+			{
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
+			},
+		],
 		locale: SEO.locale,
 		type: "website",
 	},
@@ -37,7 +40,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [...SEO.keywords, "benchmarks", "LLM evals", "coding evaluations", "model comparison"],
 }

+ 9 - 5
apps/web-roo-code/src/app/layout.tsx

@@ -2,6 +2,7 @@ import React from "react"
 import type { Metadata } from "next"
 import { Inter } from "next/font/google"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 import { CookieConsentWrapper } from "@/components/CookieConsentWrapper"
 
 import { Providers } from "@/components/providers"
@@ -12,6 +13,9 @@ import "./globals.css"
 
 const inter = Inter({ subsets: ["latin"] })
 
+const OG_TITLE = "Meet Roo Code"
+const OG_DESCRIPTION = "The AI dev team that gets things done."
+
 export const metadata: Metadata = {
 	metadataBase: new URL(SEO.url),
 	title: {
@@ -51,10 +55,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: SEO.ogImage.url,
-				width: SEO.ogImage.width,
-				height: SEO.ogImage.height,
-				alt: SEO.ogImage.alt,
+				url: ogImageUrl(OG_TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: OG_TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -64,7 +68,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: SEO.title,
 		description: SEO.description,
-		images: [SEO.ogImage.url],
+		images: [ogImageUrl(OG_TITLE, OG_DESCRIPTION)],
 	},
 	robots: {
 		index: true,

+ 8 - 7
apps/web-roo-code/src/app/legal/cookies/page.tsx

@@ -1,10 +1,11 @@
 import type { Metadata } from "next"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 
-const TITLE = "Cookie Policy"
+const TITLE = "Our Cookie Policy"
 const DESCRIPTION = "Learn about how Roo Code uses cookies to enhance your experience and provide our services."
+const OG_DESCRIPTION = ""
 const PATH = "/legal/cookies"
-const OG_IMAGE = SEO.ogImage
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -19,10 +20,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -32,7 +33,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [...SEO.keywords, "cookies", "privacy", "tracking", "analytics"],
 }

+ 7 - 6
apps/web-roo-code/src/app/legal/subprocessors/page.tsx

@@ -1,10 +1,11 @@
 import type { Metadata } from "next"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 
 const TITLE = "Subprocessors"
 const DESCRIPTION = "List of third-party subprocessors used by Roo Code to process customer data."
+const OG_DESCRIPTION = ""
 const PATH = "/legal/subprocessors"
-const OG_IMAGE = SEO.ogImage
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -19,10 +20,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -32,7 +33,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [...SEO.keywords, "subprocessors", "data processing", "GDPR", "privacy", "third-party services"],
 }

+ 8 - 7
apps/web-roo-code/src/app/pricing/page.tsx

@@ -6,13 +6,14 @@ import { Button } from "@/components/ui"
 import { AnimatedBackground } from "@/components/homepage"
 import { ContactForm } from "@/components/enterprise/contact-form"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 import { EXTERNAL_LINKS } from "@/lib/constants"
 
-const TITLE = "Pricing - Roo Code Cloud"
+const TITLE = "Roo Code Cloud Pricing"
 const DESCRIPTION =
 	"Simple, transparent pricing for Roo Code Cloud. The VS Code extension is free forever. Choose the cloud plan that fits your needs."
+const OG_DESCRIPTION = ""
 const PATH = "/pricing"
-const OG_IMAGE = SEO.ogImage
 
 const PRICE_CREDITS = 5
 
@@ -29,10 +30,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -42,7 +43,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [
 		...SEO.keywords,

+ 8 - 7
apps/web-roo-code/src/app/privacy/page.tsx

@@ -1,11 +1,12 @@
 import type { Metadata } from "next"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 
-const TITLE = "Privacy Policy"
+const TITLE = "Our Privacy Policy"
 const DESCRIPTION =
 	"Privacy policy for Roo Code Cloud and marketing website. Learn how we handle your data and protect your privacy."
+const OG_DESCRIPTION = ""
 const PATH = "/privacy"
-const OG_IMAGE = SEO.ogImage
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -20,10 +21,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -33,7 +34,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [...SEO.keywords, "privacy", "data protection", "GDPR", "security"],
 }

+ 8 - 7
apps/web-roo-code/src/app/reviewer/page.tsx

@@ -5,14 +5,15 @@ import { Button } from "@/components/ui"
 import { AnimatedBackground } from "@/components/homepage"
 import { AgentCarousel } from "@/components/reviewer/agent-carousel"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 import { EXTERNAL_LINKS } from "@/lib/constants"
 import Image from "next/image"
 
-const TITLE = "PR Reviewer · Roo Code Cloud"
+const TITLE = "PR Reviewer"
 const DESCRIPTION =
 	"Get comprehensive AI-powered PR reviews that save you time, not tokens. Bring your own API key and leverage advanced reasoning, repository-aware analysis, and actionable feedback to keep your PR queue moving."
+const OG_DESCRIPTION = "AI-powered PR reviews that save you time, not tokens"
 const PATH = "/reviewer"
-const OG_IMAGE = SEO.ogImage
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -27,10 +28,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -40,7 +41,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [
 		...SEO.keywords,

+ 8 - 7
apps/web-roo-code/src/app/terms/page.tsx

@@ -1,16 +1,17 @@
 import type { Metadata } from "next"
 import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
 import fs from "fs"
 import path from "path"
 import ReactMarkdown from "react-markdown"
 import remarkGfm from "remark-gfm"
 import rehypeRaw from "rehype-raw"
 
-const TITLE = "Terms of Service"
+const TITLE = "Our Terms of Service"
 const DESCRIPTION =
 	"Terms of Service for Roo Code Cloud. Learn about our service terms, commercial conditions, and legal framework."
+const OG_DESCRIPTION = ""
 const PATH = "/terms"
-const OG_IMAGE = SEO.ogImage
 
 export const metadata: Metadata = {
 	title: TITLE,
@@ -25,10 +26,10 @@ export const metadata: Metadata = {
 		siteName: SEO.name,
 		images: [
 			{
-				url: OG_IMAGE.url,
-				width: OG_IMAGE.width,
-				height: OG_IMAGE.height,
-				alt: OG_IMAGE.alt,
+				url: ogImageUrl(TITLE, OG_DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
 			},
 		],
 		locale: SEO.locale,
@@ -38,7 +39,7 @@ export const metadata: Metadata = {
 		card: SEO.twitterCard,
 		title: TITLE,
 		description: DESCRIPTION,
-		images: [OG_IMAGE.url],
+		images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
 	},
 	keywords: [...SEO.keywords, "terms of service", "legal", "agreement", "subscription"],
 }

+ 57 - 0
apps/web-roo-code/src/lib/og.ts

@@ -0,0 +1,57 @@
+/**
+ * Generate a dynamic OpenGraph image URL
+ * @param title - The title to display on the OG image
+ * @param description - Optional description to display (will be truncated to ~140 chars)
+ * @returns Absolute URL to the dynamic OG image endpoint
+ */
+export function ogImageUrl(title: string, description?: string): string {
+	const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://roocode.com"
+	const params = new URLSearchParams()
+
+	params.set("title", title)
+	if (description) {
+		params.set("description", description)
+	}
+
+	return `${baseUrl}/api/og?${params.toString()}`
+}
+
+/**
+ * Generate OpenGraph metadata for a page with dynamic image
+ * @param title - The page title
+ * @param description - The page description
+ * @returns OpenGraph metadata object with dynamic image
+ */
+export function getOgMetadata(title: string, description: string) {
+	const imageUrl = ogImageUrl(title, description)
+
+	return {
+		title,
+		description,
+		images: [
+			{
+				url: imageUrl,
+				width: 1200,
+				height: 630,
+				alt: title,
+			},
+		],
+	}
+}
+
+/**
+ * Generate Twitter metadata for a page with dynamic image
+ * @param title - The page title
+ * @param description - The page description
+ * @returns Twitter metadata object with dynamic image
+ */
+export function getTwitterMetadata(title: string, description: string) {
+	const imageUrl = ogImageUrl(title, description)
+
+	return {
+		card: "summary_large_image" as const,
+		title,
+		description,
+		images: [imageUrl],
+	}
+}

+ 125 - 1
pnpm-lock.yaml

@@ -269,6 +269,9 @@ importers:
       '@tanstack/react-query':
         specifier: ^5.79.0
         version: 5.80.2([email protected])
+      '@vercel/og':
+        specifier: ^0.6.2
+        version: 0.6.8
       class-variance-authority:
         specifier: ^0.7.1
         version: 0.7.1
@@ -3166,6 +3169,10 @@ packages:
     peerDependencies:
       '@redis/client': ^5.5.5
 
+  '@resvg/[email protected]':
+    resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==}
+    engines: {node: '>= 10'}
+
   '@rollup/[email protected]':
     resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==}
     cpu: [arm]
@@ -3293,6 +3300,11 @@ packages:
   '@shikijs/[email protected]':
     resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
 
+  '@shuding/[email protected]':
+    resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
+    engines: {node: '>= 8.0.0'}
+    hasBin: true
+
   '@sinclair/[email protected]':
     resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
 
@@ -4211,6 +4223,10 @@ packages:
   '@ungap/[email protected]':
     resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
 
+  '@vercel/[email protected]':
+    resolution: {integrity: sha512-e4kQK9mP8ntpo3dACWirGod/hHv4qO5JMj9a/0a2AZto7b4persj5YP7t1Er372gTtYFTYxNhMx34jRvHooglw==}
+    engines: {node: '>=16'}
+
   '@vitejs/[email protected]':
     resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==}
     engines: {node: ^14.18.0 || >=16.0.0}
@@ -4558,6 +4574,10 @@ packages:
       bare-events:
         optional: true
 
+  [email protected]:
+    resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
+    engines: {node: '>= 0.4'}
+
   [email protected]:
     resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
 
@@ -4997,10 +5017,20 @@ packages:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  [email protected]:
+    resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
+
+  [email protected]:
+    resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
+
   [email protected]:
     resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
     engines: {node: '>=4'}
 
+  [email protected]:
+    resolution: {integrity: sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==}
+    engines: {node: '>=16'}
+
   [email protected]:
     resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
 
@@ -5983,6 +6013,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
 
+  [email protected]:
+    resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
+
   [email protected]:
     resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
 
@@ -6382,6 +6415,10 @@ packages:
     resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
     hasBin: true
 
+  [email protected]:
+    resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
+    engines: {node: '>=6'}
+
   [email protected]:
     resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
     engines: {node: '>=12.0.0'}
@@ -7145,6 +7182,9 @@ packages:
     resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
     engines: {node: '>=14'}
 
+  [email protected]:
+    resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
+
   [email protected]:
     resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
 
@@ -7997,6 +8037,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==}
 
+  [email protected]:
+    resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
+
   [email protected]:
     resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
 
@@ -8004,6 +8047,9 @@ packages:
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     engines: {node: '>=6'}
 
+  [email protected]:
+    resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
+
   [email protected]:
     resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
 
@@ -8735,6 +8781,10 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==}
 
+  [email protected]:
+    resolution: {integrity: sha512-3C/laIeE6UUe9A+iQ0A48ywPVCCMKCNSTU5Os101Vhgsjd3AAxGNjyq0uAA8kulMPK5n0csn8JlxPN9riXEjLA==}
+    engines: {node: '>=16'}
+
   [email protected]:
     resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
 
@@ -9052,6 +9102,9 @@ packages:
     resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
     engines: {node: '>=18'}
 
+  [email protected]:
+    resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
+
   [email protected]:
     resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
     engines: {node: '>= 0.4'}
@@ -9280,6 +9333,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-/kqtlepLMptX0OgbYD9aMYbM7EFrMZCL7EoHM8Psmg2FuhXoo/bH64KqOiZGGwa6oS9TPdSEDKBnV2LuB8+5vQ==}
 
+  [email protected]:
+    resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
+
   [email protected]:
     resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
 
@@ -9546,6 +9602,9 @@ packages:
     resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
     engines: {node: '>=18.17'}
 
+  [email protected]:
+    resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
+
   [email protected]:
     resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
     engines: {node: '>=18'}
@@ -10155,6 +10214,9 @@ packages:
     resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
     engines: {node: '>=18'}
 
+  [email protected]:
+    resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
+
   [email protected]:
     resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
     engines: {node: '>= 10'}
@@ -12628,6 +12690,8 @@ snapshots:
     dependencies:
       '@redis/client': 5.5.5
 
+  '@resvg/[email protected]': {}
+
   '@rollup/[email protected]':
     optional: true
 
@@ -12725,6 +12789,11 @@ snapshots:
 
   '@shikijs/[email protected]': {}
 
+  '@shuding/[email protected]':
+    dependencies:
+      fflate: 0.7.4
+      string.prototype.codepointat: 0.2.1
+
   '@sinclair/[email protected]': {}
 
   '@sindresorhus/[email protected]': {}
@@ -13847,6 +13916,12 @@ snapshots:
 
   '@ungap/[email protected]': {}
 
+  '@vercel/[email protected]':
+    dependencies:
+      '@resvg/resvg-wasm': 2.4.0
+      satori: 0.12.2
+      yoga-wasm-web: 0.3.3
+
   '@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
     dependencies:
       '@babel/core': 7.27.1
@@ -13919,7 +13994,7 @@ snapshots:
       sirv: 3.0.1
       tinyglobby: 0.2.14
       tinyrainbow: 2.0.0
-      vitest: 3.2.4(@types/[email protected])(@types/node@20.17.57)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
+      vitest: 3.2.4(@types/[email protected])(@types/node@24.2.1)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
   '@vitest/[email protected]':
     dependencies:
@@ -14290,6 +14365,8 @@ snapshots:
       bare-events: 2.5.4
     optional: true
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -14758,8 +14835,14 @@ snapshots:
       shebang-command: 2.0.0
       which: 2.0.2
 
+  [email protected]: {}
+
+  [email protected]: {}
+
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       hyphenate-style-name: 1.1.0
@@ -15865,6 +15948,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]:
@@ -16381,6 +16466,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]:
@@ -17133,6 +17220,11 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      base64-js: 0.0.8
+      unicode-trie: 2.0.0
+
   [email protected]: {}
 
   [email protected]:
@@ -18276,12 +18368,19 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]:
     dependencies:
       callsites: 3.1.0
 
+  [email protected]:
+    dependencies:
+      color-name: 1.1.4
+      hex-rgb: 4.3.0
+
   [email protected]:
     dependencies:
       character-entities: 1.2.4
@@ -19154,6 +19253,20 @@ snapshots:
     dependencies:
       truncate-utf8-bytes: 1.0.2
 
+  [email protected]:
+    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.16
+      css-to-react-native: 3.2.0
+      emoji-regex: 10.4.0
+      escape-html: 1.0.3
+      linebreak: 1.1.0
+      parse-css-color: 0.2.1
+      postcss-value-parser: 4.2.0
+      yoga-wasm-web: 0.3.3
+
   [email protected]: {}
 
   [email protected]:
@@ -19532,6 +19645,8 @@ snapshots:
       get-east-asian-width: 1.3.0
       strip-ansi: 7.1.0
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       call-bind: 1.0.8
@@ -19804,6 +19919,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -20058,6 +20175,11 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      pako: 0.2.9
+      tiny-inflate: 1.0.3
+
   [email protected]: {}
 
   [email protected]:
@@ -20862,6 +20984,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       archiver-utils: 3.0.4