diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/agent/[creator]/[slug]/__tests__/generateMetadata.test.ts b/autogpt_platform/frontend/src/app/(platform)/marketplace/agent/[creator]/[slug]/__tests__/generateMetadata.test.ts new file mode 100644 index 000000000000..5bfe7434e548 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/agent/[creator]/[slug]/__tests__/generateMetadata.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mockGetSpecificAgent = vi.hoisted(() => vi.fn()); + +vi.mock("@/app/api/__generated__/endpoints/store/store", () => ({ + getV2GetSpecificAgent: mockGetSpecificAgent, + prefetchGetV2GetSpecificAgentQuery: vi.fn(), + prefetchGetV2ListStoreAgentsQuery: vi.fn(), +})); + +vi.mock("@/app/api/__generated__/endpoints/library/library", () => ({ + prefetchGetV2GetAgentByStoreIdQuery: vi.fn(), +})); + +vi.mock("@/lib/auth/server/getServerUser", () => ({ + getServerUser: vi.fn(), +})); + +vi.mock("../../../../components/MainAgentPage/MainAgentPage", () => ({ + MainAgentPage: () => null, +})); + +import { generateMetadata } from "../page"; + +const params = { creator: "pwuts", slug: "an-agent" }; + +describe("generateMetadata", () => { + beforeEach(() => { + mockGetSpecificAgent.mockReset(); + }); + + test("previews the agent's own name, description and image", async () => { + mockGetSpecificAgent.mockResolvedValue({ + data: { + agent_name: "An Agent", + description: "What the agent does", + agent_image: ["https://cdn.example.com/agent.png"], + }, + }); + + const metadata = await generateMetadata({ + params: Promise.resolve(params), + }); + + expect(metadata.title).toBe("An Agent - AutoGPT Marketplace"); + expect(metadata.openGraph?.title).toBe("An Agent - AutoGPT Marketplace"); + expect(metadata.openGraph?.description).toBe("What the agent does"); + expect(metadata.openGraph?.images).toEqual([ + "https://cdn.example.com/agent.png", + ]); + expect(metadata.twitter).toMatchObject({ card: "summary_large_image" }); + }); + + test("uses only the first image when the listing carries several", async () => { + mockGetSpecificAgent.mockResolvedValue({ + data: { + agent_name: "An Agent", + description: "What the agent does", + agent_image: ["https://cdn.example.com/1.png", "https://x/2.png"], + }, + }); + + const metadata = await generateMetadata({ + params: Promise.resolve(params), + }); + + expect(metadata.openGraph?.images).toEqual([ + "https://cdn.example.com/1.png", + ]); + }); + + test("falls back to a text card when the listing has no image", async () => { + mockGetSpecificAgent.mockResolvedValue({ + data: { + agent_name: "An Agent", + description: "What the agent does", + agent_image: [], + }, + }); + + const metadata = await generateMetadata({ + params: Promise.resolve(params), + }); + + expect(metadata.openGraph).not.toHaveProperty("images"); + expect(metadata.twitter).toMatchObject({ card: "summary" }); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/agent/[creator]/[slug]/page.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/agent/[creator]/[slug]/page.tsx index b1c06a045312..0f69b77e7eba 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/agent/[creator]/[slug]/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/agent/[creator]/[slug]/page.tsx @@ -7,6 +7,7 @@ import { import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails"; import { getQueryClient } from "@/lib/react-query/queryClient"; import { getServerUser } from "@/lib/auth/server/getServerUser"; +import { buildPageMetadata } from "@/lib/metadata"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { Metadata } from "next"; import { MainAgentPage } from "../../../components/MainAgentPage/MainAgentPage"; @@ -21,14 +22,16 @@ export async function generateMetadata({ params: Promise; }): Promise { const params = await _params; - const { data: creator_agent } = await getV2GetSpecificAgent( - params.creator, - params.slug, - ); - return { - title: `${(creator_agent as StoreAgentDetails).agent_name} - AutoGPT Marketplace`, - description: (creator_agent as StoreAgentDetails).description, - }; + const { data } = await getV2GetSpecificAgent(params.creator, params.slug); + const agent = data as StoreAgentDetails; + + return buildPageMetadata({ + title: `${agent.agent_name} - AutoGPT Marketplace`, + description: agent.description, + path: `/marketplace/agent/${params.creator}/${params.slug}`, + images: agent.agent_image?.slice(0, 1), + type: "article", + }); } export default async function MarketplaceAgentPage({ diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/__tests__/generateMetadata.test.ts b/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/__tests__/generateMetadata.test.ts index 0bf6dd4d8788..75773bb5a95a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/__tests__/generateMetadata.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/__tests__/generateMetadata.test.ts @@ -32,7 +32,11 @@ describe("generateMetadata", () => { test("returns creator metadata on success", async () => { mockGetCreatorDetails.mockResolvedValue({ - data: { name: "Creator One", description: "Creator profile" }, + data: { + name: "Creator One", + description: "Creator profile", + avatar_url: "https://cdn.example.com/avatar.png", + }, }); const metadata = await generateMetadata({ @@ -42,6 +46,32 @@ describe("generateMetadata", () => { expect(mockGetCreatorDetails).toHaveBeenCalledWith("creator-one"); expect(metadata.title).toBe("Creator One - AutoGPT Store"); expect(metadata.description).toBe("Creator profile"); + expect(metadata.openGraph).toMatchObject({ + title: "Creator One - AutoGPT Store", + description: "Creator profile", + type: "profile", + }); + expect(metadata.openGraph?.images).toEqual([ + "https://cdn.example.com/avatar.png", + ]); + expect(metadata.twitter).toMatchObject({ card: "summary_large_image" }); + }); + + test("falls back to a text card when the creator has no avatar", async () => { + mockGetCreatorDetails.mockResolvedValue({ + data: { + name: "Creator One", + description: "Creator profile", + avatar_url: null, + }, + }); + + const metadata = await generateMetadata({ + params: Promise.resolve({ creator: "creator-one" }), + }); + + expect(metadata.openGraph).not.toHaveProperty("images"); + expect(metadata.twitter).toMatchObject({ card: "summary" }); }); test("renders the 404 page when the creator does not exist", async () => { diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/page.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/page.tsx index 0dab09be2b7e..a9b0fcdc2bb3 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/creator/[creator]/page.tsx @@ -6,6 +6,7 @@ import { import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails"; import { ApiError } from "@/lib/autogpt-server-api/helpers"; import { getQueryClient } from "@/lib/react-query/queryClient"; +import { buildPageMetadata } from "@/lib/metadata"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { Metadata } from "next"; import { notFound } from "next/navigation"; @@ -35,10 +36,13 @@ export async function generateMetadata({ throw error; } - return { + return buildPageMetadata({ title: `${creator.name} - AutoGPT Store`, description: creator.description, - }; + path: `/marketplace/creator/${params.creator}`, + images: [creator.avatar_url], + type: "profile", + }); } export default async function Page({ diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/[expertId]/components/ExpertPage.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/[expertId]/components/ExpertPage.tsx new file mode 100644 index 000000000000..d74cc7741de7 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/[expertId]/components/ExpertPage.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { getExpertAccent } from "@/app/(platform)/marketplace/components/ExpertsSection/helpers"; +import { Icon } from "@/components/atoms/Icon/Icon"; +import { Skeleton } from "@/components/atoms/Skeleton/Skeleton"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; +import { VoicePicker } from "@/components/organisms/VoicePicker/VoicePicker"; +import { ArrowLeft02Icon } from "@hugeicons/core-free-icons"; +import Link from "next/link"; +import { notFound, useParams } from "next/navigation"; +import { ReactNode } from "react"; +import { ExpertAbout } from "./ExpertAbout"; +import { ExpertComingSoonLabel } from "./ExpertComingSoonLabel"; +import { ExpertHireActions } from "./ExpertHireActions"; +import { ExpertPageHeader } from "./ExpertPageHeader"; +import { ExpertSkills } from "./ExpertSkills"; +import { ExpertWorkflowList } from "./ExpertWorkflowList"; +import { useExpertPage } from "../useExpertPage"; +import { useHireFlow } from "../useHireFlow"; + +const MAIN_CLASS = + "mx-auto flex w-full max-w-[760px] flex-col px-6 pb-24 pt-8 md:px-8"; + +function BackToMarketplaceLink() { + return ( + + + Back to marketplace + + ); +} + +export function ExpertPage() { + const { expertId } = useParams<{ expertId: string }>(); + const { + expert, + hiredExpert, + isLoggedIn, + isHiringOpen, + isActionReady, + isLoading, + isError, + refetch, + } = useExpertPage({ expertId }); + const { + hire, + isHiring, + hireResult, + pickVoice, + skipVoice, + dismissVoicePick, + isSavingVoice, + } = useHireFlow(expert); + + if (isLoading) { + return ( +
+ +
+ +
+ + +
+ +
+ +
+ + + +
+
+ ); + } + + if (isError) { + return ( +
+ + refetch()} + /> +
+ ); + } + + if (!expert) { + notFound(); + } + + const accent = getExpertAccent(expert.role); + + let actions: ReactNode = ; + if (isActionReady) { + actions = isHiringOpen ? ( + + ) : ( + + ); + } + + return ( +
+ + +
+ + + +
+ + {/* The voice pick follows a successful hire when the persona ships + writing samples; dismissing it still celebrates the hire. */} + { + if (!open) dismissVoicePick(); + }, + }} + > + + {hireResult ? ( + + ) : null} + + +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/[expertId]/page.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/[expertId]/page.tsx index e81ebe760906..2c7dc8b39060 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/[expertId]/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/[expertId]/page.tsx @@ -1,154 +1,44 @@ -"use client"; +import { listExpertTemplates } from "@/app/api/__generated__/endpoints/experts/experts"; +import { Expert } from "@/app/api/__generated__/models/expert"; +import { buildPageMetadata } from "@/lib/metadata"; +import { Metadata } from "next"; +import { ExpertPage } from "./components/ExpertPage"; + +export type MarketplaceExpertPageParams = { expertId: string }; + +export async function generateMetadata({ + params: _params, +}: { + params: Promise; +}): Promise { + const params = await _params; + const path = `/marketplace/experts/${params.expertId}`; + const expert = await findExpertTemplate(params.expertId); -import { getExpertAccent } from "@/app/(platform)/marketplace/components/ExpertsSection/helpers"; -import { Icon } from "@/components/atoms/Icon/Icon"; -import { Skeleton } from "@/components/atoms/Skeleton/Skeleton"; -import { Dialog } from "@/components/molecules/Dialog/Dialog"; -import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; -import { VoicePicker } from "@/components/organisms/VoicePicker/VoicePicker"; -import { ArrowLeft02Icon } from "@hugeicons/core-free-icons"; -import Link from "next/link"; -import { notFound, useParams } from "next/navigation"; -import { ReactNode } from "react"; -import { ExpertAbout } from "./components/ExpertAbout"; -import { ExpertComingSoonLabel } from "./components/ExpertComingSoonLabel"; -import { ExpertHireActions } from "./components/ExpertHireActions"; -import { ExpertPageHeader } from "./components/ExpertPageHeader"; -import { ExpertSkills } from "./components/ExpertSkills"; -import { ExpertWorkflowList } from "./components/ExpertWorkflowList"; -import { useExpertPage } from "./useExpertPage"; -import { useHireFlow } from "./useHireFlow"; - -const MAIN_CLASS = - "mx-auto flex w-full max-w-[760px] flex-col px-6 pb-24 pt-8 md:px-8"; + if (!expert) { + return buildPageMetadata({ title: "Expert - AutoGPT Marketplace", path }); + } -function BackToMarketplaceLink() { - return ( - - - Back to marketplace - - ); + // avatar_url points at an SVG, which no unfurler renders, so this stays a + // text card until experts have a raster image. + return buildPageMetadata({ + title: `${expert.name}, ${expert.role} - AutoGPT Marketplace`, + description: expert.tagline || expert.bio, + path, + type: "profile", + }); } export default function MarketplaceExpertPage() { - const { expertId } = useParams<{ expertId: string }>(); - const { - expert, - hiredExpert, - isLoggedIn, - isHiringOpen, - isActionReady, - isLoading, - isError, - refetch, - } = useExpertPage({ expertId }); - const { - hire, - isHiring, - hireResult, - pickVoice, - skipVoice, - dismissVoicePick, - isSavingVoice, - } = useHireFlow(expert); - - if (isLoading) { - return ( -
- -
- -
- - -
- -
- -
- - - -
-
- ); - } - - if (isError) { - return ( -
- - refetch()} - /> -
- ); - } - - if (!expert) { - notFound(); - } - - const accent = getExpertAccent(expert.role); + return ; +} - let actions: ReactNode = ; - if (isActionReady) { - actions = isHiringOpen ? ( - - ) : ( - - ); +async function findExpertTemplate(expertId: string): Promise { + try { + const { data } = await listExpertTemplates(); + return (data as Expert[]).find((t) => t.id === expertId) ?? null; + } catch { + // Metadata must never break the page; the client fetch renders the error. + return null; } - - return ( -
- - -
- - - -
- - {/* The voice pick follows a successful hire when the persona ships - writing samples; dismissing it still celebrates the hire. */} - { - if (!open) dismissVoicePick(); - }, - }} - > - - {hireResult ? ( - - ) : null} - - -
- ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/__tests__/expert-page.test.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/__tests__/expert-page.test.tsx index f0fd147b8f98..a0b9fadfdef1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/__tests__/expert-page.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/__tests__/expert-page.test.tsx @@ -11,7 +11,7 @@ import { render, screen, waitFor } from "@/tests/integrations/test-utils"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import MarketplaceExpertPage from "../[expertId]/page"; +import { ExpertPage as MarketplaceExpertPage } from "../[expertId]/components/ExpertPage"; const mockUseAuth = vi.hoisted(() => vi.fn()); const mockRouterPush = vi.hoisted(() => vi.fn()); diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/__tests__/generateMetadata.test.ts b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/__tests__/generateMetadata.test.ts new file mode 100644 index 000000000000..a5153798601c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/experts/__tests__/generateMetadata.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mockListExpertTemplates = vi.hoisted(() => vi.fn()); + +vi.mock("@/app/api/__generated__/endpoints/experts/experts", () => ({ + listExpertTemplates: mockListExpertTemplates, +})); + +vi.mock("../[expertId]/components/ExpertPage", () => ({ + ExpertPage: () => null, +})); + +import { generateMetadata } from "../[expertId]/page"; + +const maria = { + id: "template-maria", + name: "Maria", + role: "Marketing", + tagline: "Turns your product story into campaigns that land.", + bio: "A senior marketing strategist.", + avatar_url: "/experts/maria.svg", +}; + +describe("generateMetadata", () => { + beforeEach(() => { + mockListExpertTemplates.mockReset(); + }); + + test("previews the expert's name, role and tagline", async () => { + mockListExpertTemplates.mockResolvedValue({ data: [maria] }); + + const metadata = await generateMetadata({ + params: Promise.resolve({ expertId: "template-maria" }), + }); + + expect(metadata.title).toBe("Maria, Marketing - AutoGPT Marketplace"); + expect(metadata.openGraph).toMatchObject({ + title: "Maria, Marketing - AutoGPT Marketplace", + description: "Turns your product story into campaigns that land.", + type: "profile", + }); + expect(metadata.alternates?.canonical).toContain( + "/marketplace/experts/template-maria", + ); + }); + + test("never uses the SVG avatar, which no unfurler renders", async () => { + mockListExpertTemplates.mockResolvedValue({ data: [maria] }); + + const metadata = await generateMetadata({ + params: Promise.resolve({ expertId: "template-maria" }), + }); + + expect(metadata.openGraph).not.toHaveProperty("images"); + expect(metadata.twitter).toMatchObject({ card: "summary" }); + }); + + test("falls back to a generic title for an unknown expert", async () => { + mockListExpertTemplates.mockResolvedValue({ data: [maria] }); + + const metadata = await generateMetadata({ + params: Promise.resolve({ expertId: "nobody" }), + }); + + expect(metadata.title).toBe("Expert - AutoGPT Marketplace"); + }); + + test("falls back rather than throwing when the API is unreachable", async () => { + mockListExpertTemplates.mockRejectedValue(new Error("fetch failed")); + + const metadata = await generateMetadata({ + params: Promise.resolve({ expertId: "template-maria" }), + }); + + expect(metadata.title).toBe("Expert - AutoGPT Marketplace"); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/page.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/page.tsx index 9b7e6ae93bbf..1b6913f8c285 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/page.tsx @@ -3,6 +3,7 @@ import { prefetchGetV2ListStoreCreatorsQuery, } from "@/app/api/__generated__/endpoints/store/store"; import { getQueryClient } from "@/lib/react-query/queryClient"; +import { buildPageMetadata } from "@/lib/metadata"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { Metadata } from "next"; import { Suspense } from "react"; @@ -11,10 +12,17 @@ import { MainMarketplacePageLoading } from "./components/MainMarketplacePageLoad export const dynamic = "force-dynamic"; -// FIX: Correct metadata +const TITLE = "Marketplace - AutoGPT Platform"; +const DESCRIPTION = "Find and use AI Agents created by our community"; + +// No og:image: the previous /images/store-og.png and store-twitter.png were +// never shipped and 404'd on every unfurl. export const metadata: Metadata = { - title: "Marketplace - AutoGPT Platform", - description: "Find and use AI Agents created by our community", + ...buildPageMetadata({ + title: TITLE, + description: DESCRIPTION, + path: "/marketplace", + }), applicationName: "AutoGPT Marketplace", authors: [{ name: "AutoGPT Team" }], keywords: [ @@ -28,26 +36,6 @@ export const metadata: Metadata = { index: true, follow: true, }, - openGraph: { - title: "Marketplace - AutoGPT Platform", - description: "Find and use AI Agents created by our community", - type: "website", - siteName: "AutoGPT Marketplace", - images: [ - { - url: "/images/store-og.png", - width: 1200, - height: 630, - alt: "AutoGPT Marketplace", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "Marketplace - AutoGPT Platform", - description: "Find and use AI Agents created by our community", - images: ["/images/store-twitter.png"], - }, }; export default async function MarketplacePage(): Promise { diff --git a/autogpt_platform/frontend/src/app/layout.tsx b/autogpt_platform/frontend/src/app/layout.tsx index 843e9d24330f..7769e256b632 100644 --- a/autogpt_platform/frontend/src/app/layout.tsx +++ b/autogpt_platform/frontend/src/app/layout.tsx @@ -15,6 +15,7 @@ import { environment } from "@/services/environment"; import AgentationDevtool from "@/components/AgentationDevtool"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { headers } from "next/headers"; +import { getSiteUrl } from "@/lib/metadata"; const isDev = environment.isDev(); const isLocal = environment.isLocal(); @@ -25,14 +26,29 @@ const faviconPath = isDev ? "/favicon-local.ico" : "/favicon.ico"; +const SITE_TITLE = "AutoGPT Platform"; +const SITE_DESCRIPTION = "Your one stop shop to creating AI Agents"; + export const metadata: Metadata = { - title: "AutoGPT Platform", - description: "Your one stop shop to creating AI Agents", + metadataBase: new URL(getSiteUrl()), + title: SITE_TITLE, + description: SITE_DESCRIPTION, manifest: "/manifest.webmanifest", icons: { icon: faviconPath, apple: "/apple-touch-icon.png", }, + openGraph: { + title: SITE_TITLE, + description: SITE_DESCRIPTION, + siteName: "AutoGPT", + type: "website", + }, + twitter: { + card: "summary", + title: SITE_TITLE, + description: SITE_DESCRIPTION, + }, }; export default async function RootLayout({ diff --git a/autogpt_platform/frontend/src/lib/__tests__/metadata.test.ts b/autogpt_platform/frontend/src/lib/__tests__/metadata.test.ts new file mode 100644 index 000000000000..ac5ce3f2a14a --- /dev/null +++ b/autogpt_platform/frontend/src/lib/__tests__/metadata.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { buildPageMetadata, getSiteUrl } from "../metadata"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("buildPageMetadata", () => { + test("emits a large-image card when an image is given", () => { + const metadata = buildPageMetadata({ + title: "An Agent", + description: "What it does", + path: "/marketplace/agent/pwuts/an-agent", + images: ["https://cdn.example.com/agent.png"], + type: "article", + }); + + expect(metadata.openGraph?.images).toEqual([ + "https://cdn.example.com/agent.png", + ]); + expect(metadata.twitter).toMatchObject({ card: "summary_large_image" }); + expect(metadata.twitter?.images).toEqual([ + "https://cdn.example.com/agent.png", + ]); + expect(metadata.openGraph).toMatchObject({ + title: "An Agent", + description: "What it does", + siteName: "AutoGPT", + type: "article", + }); + }); + + test.each([ + ["no images key", undefined], + ["an empty list", []], + ["a null entry", [null]], + ["an empty string", [""]], + ])("omits og:image given %s", (_label, images) => { + const metadata = buildPageMetadata({ title: "Untitled", images }); + + expect(metadata.openGraph).not.toHaveProperty("images"); + expect(metadata.twitter).not.toHaveProperty("images"); + expect(metadata.twitter).toMatchObject({ card: "summary" }); + }); + + test("resolves the canonical and og:url against the site URL", () => { + vi.stubEnv("NEXT_PUBLIC_FRONTEND_BASE_URL", "https://platform.agpt.co"); + + const metadata = buildPageMetadata({ + title: "Marketplace", + path: "/marketplace", + }); + + expect(metadata.alternates?.canonical).toBe( + "https://platform.agpt.co/marketplace", + ); + expect(metadata.openGraph?.url).toBe( + "https://platform.agpt.co/marketplace", + ); + }); + + test("leaves the canonical unset when no path is given", () => { + const metadata = buildPageMetadata({ title: "Marketplace" }); + + expect(metadata.alternates).toBeUndefined(); + expect(metadata.openGraph?.url).toBeUndefined(); + }); + + test("drops an empty description rather than emitting an empty tag", () => { + const metadata = buildPageMetadata({ title: "A Creator", description: "" }); + + expect(metadata.description).toBeUndefined(); + expect(metadata.openGraph?.description).toBeUndefined(); + }); +}); + +describe("getSiteUrl", () => { + test("prefers the configured frontend base URL", () => { + vi.stubEnv("NEXT_PUBLIC_FRONTEND_BASE_URL", "https://platform.agpt.co"); + vi.stubEnv("VERCEL_URL", "some-deployment.vercel.app"); + + expect(getSiteUrl()).toBe("https://platform.agpt.co"); + }); + + test("falls back to VERCEL_URL as an absolute origin", () => { + vi.stubEnv("NEXT_PUBLIC_FRONTEND_BASE_URL", ""); + vi.stubEnv("VERCEL_URL", "some-deployment.vercel.app"); + + expect(getSiteUrl()).toBe("https://some-deployment.vercel.app"); + }); + + test("falls back to localhost with neither set", () => { + vi.stubEnv("NEXT_PUBLIC_FRONTEND_BASE_URL", ""); + vi.stubEnv("VERCEL_URL", ""); + + expect(getSiteUrl()).toBe("http://localhost:3000"); + }); + + test("skips a configured origin that is not a valid URL", () => { + vi.stubEnv("NEXT_PUBLIC_FRONTEND_BASE_URL", "platform.agpt.co"); + vi.stubEnv("VERCEL_URL", "some-deployment.vercel.app"); + + expect(getSiteUrl()).toBe("https://some-deployment.vercel.app"); + }); + + test.each([ + ["mailto:", "mailto:hello@agpt.co"], + ["data:", "data:text/html,

hi

"], + ])("skips a %s candidate and uses the next one", (_label, configured) => { + vi.stubEnv("NEXT_PUBLIC_FRONTEND_BASE_URL", configured); + vi.stubEnv("VERCEL_URL", "some-deployment.vercel.app"); + + expect(getSiteUrl()).toBe("https://some-deployment.vercel.app"); + expect(() => + buildPageMetadata({ title: "Marketplace", path: "/marketplace" }), + ).not.toThrow(); + }); + + test("always returns an origin new URL() accepts", () => { + vi.stubEnv("NEXT_PUBLIC_FRONTEND_BASE_URL", "not a url"); + vi.stubEnv("VERCEL_URL", "also not a url"); + + expect(() => new URL(getSiteUrl())).not.toThrow(); + }); +}); diff --git a/autogpt_platform/frontend/src/lib/metadata.ts b/autogpt_platform/frontend/src/lib/metadata.ts new file mode 100644 index 000000000000..35f45c8a53a1 --- /dev/null +++ b/autogpt_platform/frontend/src/lib/metadata.ts @@ -0,0 +1,78 @@ +import type { Metadata } from "next"; + +const SITE_NAME = "AutoGPT"; + +interface PageMetadataOptions { + title: string; + description?: string | null; + path?: string; + images?: (string | null | undefined)[]; + type?: "website" | "article" | "profile"; +} + +// Emits og:image only for a real image; unfurlers render a broken one worse +// than none at all. +export function buildPageMetadata({ + title, + description, + path, + images, + type = "website", +}: PageMetadataOptions): Metadata { + const url = path ? new URL(path, getSiteUrl()).toString() : undefined; + const cardImages = (images ?? []).filter( + (image): image is string => typeof image === "string" && image.length > 0, + ); + const summary = description || undefined; + + return { + title, + description: summary, + alternates: url ? { canonical: url } : undefined, + openGraph: { + title, + description: summary, + siteName: SITE_NAME, + type, + url, + ...(cardImages.length > 0 ? { images: cardImages } : {}), + }, + twitter: { + card: cardImages.length > 0 ? "summary_large_image" : "summary", + title, + description: summary, + ...(cardImages.length > 0 ? { images: cardImages } : {}), + }, + }; +} + +// Falls back rather than returning a value `new URL()` would reject: the root +// layout builds metadataBase from this, so a bad origin here fails the build. +export function getSiteUrl(): string { + const vercel = process.env.VERCEL_URL; + + return ( + firstValidOrigin([ + process.env.NEXT_PUBLIC_FRONTEND_BASE_URL, + vercel ? `https://${vercel}` : undefined, + ]) ?? "http://localhost:3000" + ); +} + +function firstValidOrigin( + candidates: (string | undefined)[], +): string | undefined { + for (const candidate of candidates) { + if (!candidate) continue; + try { + const url = new URL(candidate); + // mailto: and data: parse but have no origin, so resolving a relative + // path against one throws instead of falling through to the next. + if (url.protocol !== "http:" && url.protocol !== "https:") continue; + return url.toString().replace(/\/$/, ""); + } catch { + continue; + } + } + return undefined; +}