diff --git a/app/[locale]/roadmap/page.tsx b/app/[locale]/roadmap/page.tsx
index 7bc113e023a..127a8e589c9 100644
--- a/app/[locale]/roadmap/page.tsx
+++ b/app/[locale]/roadmap/page.tsx
@@ -41,7 +41,7 @@ export async function generateMetadata({
slug: ["roadmap"],
title: t("page-roadmap-meta-title"),
description: t("page-roadmap-meta-description"),
- image: "/images/roadmap/roadmap-hub-hero.png",
+ image: "/images/heroes/roadmap-hub-hero.jpg",
})
}
diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx
new file mode 100644
index 00000000000..051199ed8ee
--- /dev/null
+++ b/app/api/og/route.tsx
@@ -0,0 +1,149 @@
+/* eslint-disable @next/next/no-img-element */
+import { ImageResponse } from "next/og"
+
+import { DEFAULT_OG_IMAGE, SITE_URL } from "@/lib/constants"
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url)
+ const title = searchParams.get("title")
+ const slugString = searchParams.get("slug")
+ const imageParam = searchParams.get("image")
+
+ const src = (() => {
+ if (!imageParam) return DEFAULT_OG_IMAGE
+ return imageParam.startsWith("http")
+ ? imageParam
+ : new URL(imageParam, SITE_URL).toString()
+ })()
+
+ // Load Roboto fonts (Inter variable and woff2 not supported in ImageResponse)
+ const [regularFontResponse, boldFontResponse] = await Promise.all([
+ fetch(new URL("/fonts/Roboto-Regular.ttf", SITE_URL)),
+ fetch(new URL("/fonts/Roboto-Bold.ttf", SITE_URL)),
+ ])
+
+ const [regularFontData, boldFontData] = await Promise.all([
+ regularFontResponse.arrayBuffer(),
+ boldFontResponse.arrayBuffer(),
+ ])
+
+ const imageResponse = new ImageResponse(
+ (
+
+
+ {/* Background image layer */}
+

+
+ {/* Content overlay with safe zones -- image only if empty slug */}
+ {slugString?.length && (
+
+
+ {/* Logo and breadcrumb */}
+
+
+
+ ethereum.org/{slugString.split("/")[0]}
+
+
+
+ {/* Main title */}
+ {title && (
+
+ {title.split(" | ")[0]}
+
+ )}
+
+
+ )}
+
+ ),
+ {
+ width: 1200,
+ height: 630,
+ fonts: [
+ {
+ name: "Roboto",
+ data: regularFontData,
+ style: "normal",
+ weight: 400,
+ },
+ {
+ name: "Roboto",
+ data: boldFontData,
+ style: "normal",
+ weight: 700,
+ },
+ ],
+ }
+ )
+
+ // Set cache headers to prevent stale content
+ const response = new Response(imageResponse.body, {
+ headers: {
+ ...imageResponse.headers,
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ Pragma: "no-cache",
+ Expires: "0",
+ },
+ })
+
+ return response
+}
diff --git a/app/api/og/tutorial/route.tsx b/app/api/og/tutorial/route.tsx
new file mode 100644
index 00000000000..838497ee6a6
--- /dev/null
+++ b/app/api/og/tutorial/route.tsx
@@ -0,0 +1,319 @@
+import { ImageResponse } from "next/og"
+
+import { SITE_URL } from "@/lib/constants"
+
+export function GET(request: Request) {
+ const url = new URL(request.url)
+ console.log({ urlSTIRNGINGINGS: url })
+
+ // Extract values from URL params (now required)
+ const title = url.searchParams.get("title")
+ let subtitle = url.searchParams.get("subtitle")
+ const author = url.searchParams.get("author")
+ // const date = url.searchParams.get("date")
+ const estimatedReadTime = url.searchParams.get("timeToRead")
+ const skill = url.searchParams.get("skill")
+
+ const MAX_DESCRIPTION_LENGTH = 150
+ if (subtitle && subtitle.length > MAX_DESCRIPTION_LENGTH) {
+ // Find the last space before the MAX_DESCRIPTION_LENGTH character limit
+ const lastSpaceIndex = subtitle.lastIndexOf(" ", MAX_DESCRIPTION_LENGTH)
+ // If a space was found, truncate at that position, otherwise just truncate at MAX_DESCRIPTION_LENGTH
+ const truncateIndex =
+ lastSpaceIndex > 0 ? lastSpaceIndex : MAX_DESCRIPTION_LENGTH
+ subtitle = subtitle.slice(0, truncateIndex) + "..."
+ }
+
+ // Handle tags - can be passed as comma-separated string
+ const tagsParam = url.searchParams.get("tags")
+ const tags = tagsParam ? tagsParam.split(",").map((tag) => tag.trim()) : null
+
+ // Simple hex colors that work with Next.js ImageResponse
+ const colors = {
+ background: "#000000",
+ backgroundHigh: "#1c1c1c",
+ backgroundMedium: "#212121",
+ backgroundHighlight: "#f7f7f7",
+ body: "#ffffff",
+ bodyMedium: "#8c8c8c",
+ bodyLight: "#cfcfcf",
+ primary: "#7c3aed",
+ primaryHighContrast: "#5b21b6",
+ primaryLowContrast: "#f3e8ff",
+ primaryHover: "#8b5cf6",
+ accentA: "#3b82f6",
+ accentAHover: "#60a5fa",
+ accentB: "#ec4899",
+ accentC: "#0d9488",
+ success: "#059669",
+ successLight: "#dcfce7",
+ border: "#cfcfcf",
+ borderHighContrast: "#8c8c8c",
+ borderLowContrast: "#f3f3f3",
+ }
+
+ const imageResponse = new ImageResponse(
+ (
+
+ {/* Left side - Content */}
+
+ {/* Header */}
+
+
+
+ ethereum.org/developers
+
+
+
+ {/* Main Content */}
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {/* Tags */}
+ {tags && tags.length > 0 && (
+
+ {tags.slice(0, 5).map((tag) => (
+
+ {tag}
+
+ ))}
+ {tags.length > 5 && (
+
+ +{tags.length - 5} more
+
+ )}
+
+ )}
+
+
+ {/* Footer */}
+
+
+ {author && (
+
+ )}
+
+ {estimatedReadTime && (
+
+
+ {estimatedReadTime}
+
+ )}
+
+
+ {skill && (
+
+ {skill}
+
+ )}
+
+
+
+ {/* Right side - Visual */}
+
+
+ ),
+ {
+ width: 1200,
+ height: 630,
+ }
+ )
+
+ // Set cache headers to prevent stale content
+ const response = new Response(imageResponse.body, {
+ headers: {
+ ...imageResponse.headers,
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ Pragma: "no-cache",
+ Expires: "0",
+ },
+ })
+
+ return response
+}
diff --git a/public/fonts/Roboto-Bold.ttf b/public/fonts/Roboto-Bold.ttf
new file mode 100644
index 00000000000..a355c27cde0
Binary files /dev/null and b/public/fonts/Roboto-Bold.ttf differ
diff --git a/public/fonts/Roboto-Regular.ttf b/public/fonts/Roboto-Regular.ttf
new file mode 100644
index 00000000000..8c082c8de09
Binary files /dev/null and b/public/fonts/Roboto-Regular.ttf differ
diff --git a/src/components/MdComponents/index.tsx b/src/components/MdComponents/index.tsx
index 263057ebf58..eb0dbd2d7e1 100644
--- a/src/components/MdComponents/index.tsx
+++ b/src/components/MdComponents/index.tsx
@@ -176,9 +176,9 @@ export const reactComponents = {
/**
* All base markdown components as default export
*/
-const MdComponents = {
+const mdComponents = {
...htmlElements,
...reactComponents,
}
-export default MdComponents
+export default mdComponents
diff --git a/src/intl/en/page-layer-2-networks.json b/src/intl/en/page-layer-2-networks.json
index 06c73943b9f..84e6995627a 100644
--- a/src/intl/en/page-layer-2-networks.json
+++ b/src/intl/en/page-layer-2-networks.json
@@ -1,6 +1,6 @@
{
"page-layer-2-networks-hero-description": "Using Ethereum today means interacting with hundreds of different networks and apps. All backed by Ethereum as the foundational backbone.",
- "page-layer-2-networks-meta-title": "Ethereum Layer 2:Explore networks",
+ "page-layer-2-networks-meta-title": "Ethereum Layer 2: Explore networks",
"page-layer-2-networks-more-advanced-title": "Looking for more advanced overview?",
"page-layer-2-networks-more-advanced-descripton-1": "Many of the projects are",
"page-layer-2-networks-more-advanced-descripton-2": "still young and somewhat experimental.",
diff --git a/src/lib/md/metadata.ts b/src/lib/md/metadata.ts
index 130a444dea2..c42e4411b2f 100644
--- a/src/lib/md/metadata.ts
+++ b/src/lib/md/metadata.ts
@@ -6,16 +6,18 @@ import { importMd } from "./import"
export const getMdMetadata = async ({
locale,
slug: slugArray,
+ timeToRead,
}: {
locale: string
slug: string[]
+ timeToRead?: string
}) => {
const slug = slugArray.join("/")
const { markdown } = await importMd(locale, slug)
const { frontmatter } = await compile({
markdown,
- slugArray: slug.split("/"),
+ slugArray,
locale,
components: {},
})
@@ -24,6 +26,8 @@ export const getMdMetadata = async ({
const description = frontmatter.description
const image = frontmatter.image
const author = frontmatter.author
+ const tags = frontmatter.tags
+ const skill = frontmatter.skill
return await getMetadata({
locale,
@@ -32,5 +36,8 @@ export const getMdMetadata = async ({
description,
image,
author,
+ tags,
+ skill,
+ timeToRead,
})
}
diff --git a/src/lib/utils/metadata.ts b/src/lib/utils/metadata.ts
index ed8b8523456..1984042e275 100644
--- a/src/lib/utils/metadata.ts
+++ b/src/lib/utils/metadata.ts
@@ -7,48 +7,93 @@ import { isLocaleValidISO639_1 } from "./translations"
import { getFullUrl } from "./url"
import { routing } from "@/i18n/routing"
-/**
- * List of default og images for different sections
- */
-const imageForSlug = [
- { section: "developers", image: "/images/heroes/developers-hub-hero.jpg" },
- { section: "roadmap", image: "/images/heroes/roadmap-hub-hero.jpg" },
- { section: "guides", image: "/images/heroes/guides-hub-hero.jpg" },
- { section: "community", image: "/images/heroes/community-hero.png" },
- { section: "staking", image: "/images/upgrades/upgrade_rhino.png" },
- { section: "10years", image: "/images/10-year-anniversary/10-year-og.png" },
-] as const
+
+type MetadataProps = {
+ locale: string
+ slug: string[]
+ title: string
+ description: string
+ author?: string
+ tags?: string[]
+ skill?: string
+ timeToRead?: string
+ image?: string
+}
/**
* Get the default OG image for a page based on the slug
* @param slug - the slug of the page
* @returns relative path of image
*/
-export const getOgImage = (slug: string[]): string => {
- let result = DEFAULT_OG_IMAGE
+export const getBaseImage = (props: MetadataProps): string => {
+ // List of default og images for different sections
+ const imageForSlug = [
+ { section: "developers", image: "/images/heroes/developers-hub-hero.jpg" },
+ { section: "roadmap", image: "/images/heroes/roadmap-hub-hero.jpg" },
+ { section: "guides", image: "/images/heroes/guides-hub-hero.jpg" },
+ { section: "community", image: "/images/heroes/community-hero.png" },
+ { section: "staking", image: "/images/upgrades/upgrade_rhino.png" },
+ { section: "10years", image: "/images/10-year-anniversary/10-year-og.png" },
+ ] as const
+
for (const item of imageForSlug) {
- if (slug.includes(item.section)) {
- result = item.image
- }
+ if (props.slug.includes(item.section)) return item.image
}
- return result
+
+ return DEFAULT_OG_IMAGE
}
-export const getMetadata = async ({
- locale,
- slug,
- title,
- description: descriptionProp,
- image,
- author,
-}: {
- locale: string
- slug: string[]
+type ImageMetadata = {
title: string
- description?: string
- image?: string
+ description: string
author?: string
-}): Promise => {
+ image: string // Could refactor to be optional and move default/fallback in here
+ slugString: string
+ timeToRead?: string
+ skill?: string
+ tags?: string[]
+}
+export const generateImgSrc = ({
+ title,
+ description,
+ author,
+ image,
+ slugString,
+ timeToRead,
+ skill,
+ tags,
+}: ImageMetadata) => {
+ const slug = slugString.split("/")
+ if (slug.includes("tutorials") && slug.length > 2) {
+ const ogImageSrc = new URL(`api/og/tutorial`, SITE_URL)
+ title && ogImageSrc.searchParams.set("title", title)
+ description && ogImageSrc.searchParams.set("subtitle", description)
+ author && ogImageSrc.searchParams.set("author", author)
+ timeToRead && ogImageSrc.searchParams.set("timeToRead", timeToRead)
+ skill && ogImageSrc.searchParams.set("skill", skill)
+ tags && ogImageSrc.searchParams.set("tags", tags?.join(", "))
+ return ogImageSrc.toString()
+ }
+
+ // create string for custom og url, passing each of these as params to /api/og
+ const ogImageSrc = new URL(`api/og`, SITE_URL)
+ ogImageSrc.searchParams.set("title", title)
+ ogImageSrc.searchParams.set("description", description)
+ author && ogImageSrc.searchParams.set("author", author)
+ ogImageSrc.searchParams.set("image", image)
+ ogImageSrc.searchParams.set("slug", slugString)
+ return ogImageSrc.toString()
+}
+
+export const getMetadata = async (props: MetadataProps): Promise => {
+ const {
+ slug,
+ author,
+ locale,
+ image,
+ title,
+ description: descriptionProp,
+ } = props
const slugString = slug.join("/")
const t = await getTranslations({ locale, namespace: "common" })
@@ -62,7 +107,17 @@ export const getMetadata = async ({
const xDefault = getFullUrl(routing.defaultLocale, slugString)
/* Set fallback ogImage based on path */
- const ogImage = image || getOgImage(slug)
+ const baseImgSrc = image || getBaseImage(props)
+ const ogImage = generateImgSrc({
+ title,
+ description,
+ author,
+ image: baseImgSrc,
+ slugString,
+ timeToRead: props.timeToRead,
+ skill: props.skill,
+ tags: props.tags,
+ })
return {
title,