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 && ( +
+ + + + + + {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,