mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 14:05:05 +08:00
## Summary
- Adds `breadcrumbListSchema(items)` helper to `apps/www/lib/json-ld.ts`
and a hand-curated `apps/www/lib/breadcrumbs.ts` route map.
- Wires inline `<script type="application/ld+json">` BreadcrumbList
blocks into 18 marketing surfaces: blog (index + slug), customers (index
+ slug), events (index + slug), 5 product pages (database, auth,
storage, edge-functions, realtime), 3 modules (vector, cron, queues),
pricing, careers, company, features.
- Pages router callers wrap the script in `<Head>`; app router callers
place it directly in JSX. Dynamic surfaces append a leaf at render time
using the page's title (`frontmatter.title` for blog, `meta_title ??
title` for customers, `event.meta_title ?? event.title` for events).
- Modules sit at `Home > {Name}` since no `/modules` index page exists;
products sit at `Home > {Product}` (no shared products parent). Absolute
`https://supabase.com` URLs match the existing `CANONICAL_ORIGIN`
convention so anchors stay stable across Vercel previews.
Linear:
[GROWTH-822](https://linear.app/supabase/issue/GROWTH-822/add-breadcrumblist-json-ld-to-www-marketing-surfaces)
(sub-issue under
[GROWTH-724](https://linear.app/supabase/issue/GROWTH-724)).
> **Note on branch name:** the branch is
`pamela/growth-820-www-breadcrumb-jsonld`; the actual Linear issue is
GROWTH-822. The branch was named before the sub-issue was created.
Ignore the `820` in the branch.
Explicitly deferred (separate PRs / low SEO ROI): `/launch-week/*`,
`/solutions/*`, `/partners/*`, `/alternatives/*`, `/changelog`,
`/legal/dpa`, `/aws-reinvent-2025`, `/wrapped`, `/contribute/*`,
`/brand-assets`, `/ga`, `/ga-week`, `/state-of-startups*`, and the
homepage (Organization + WebSite already cover homepage entity signals;
single-item BreadcrumbList is ignored by Google).
## Test plan
- [x] On the Vercel preview, `curl -s https://<preview>/database | grep
'"BreadcrumbList"'` returns the script block with `Home > Database`.
- [x] `curl -s https://<preview>/blog/<recent-slug> | grep
'"BreadcrumbList"'` returns `Home > Blog > {post title}`.
- [x] `curl -s https://<preview>/customers/<slug> | grep
'"BreadcrumbList"'` returns `Home > Customer Stories > {customer
title}`.
- [x] `curl -s https://<preview>/events/<slug> | grep
'"BreadcrumbList"'` returns `Home > Events > {event title}`.
- [x] `curl -s https://<preview>/modules/vector | grep
'"BreadcrumbList"'` returns `Home > Vector`.
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
import {
|
|
DesktopComputerIcon,
|
|
HandIcon,
|
|
MicrophoneIcon,
|
|
VideoCameraIcon,
|
|
} from '@heroicons/react/solid'
|
|
import * as supabaseLogoWordmarkDark from 'common/assets/images/supabase-logo-wordmark--dark.png'
|
|
import * as supabaseLogoWordmarkLight from 'common/assets/images/supabase-logo-wordmark--light.png'
|
|
import dayjs from 'dayjs'
|
|
import advancedFormat from 'dayjs/plugin/advancedFormat'
|
|
import timezone from 'dayjs/plugin/timezone'
|
|
import utc from 'dayjs/plugin/utc'
|
|
import matter from 'gray-matter'
|
|
import { ChevronLeft, X as XIcon } from 'lucide-react'
|
|
import type { GetStaticProps, InferGetStaticPropsType } from 'next'
|
|
import { MDXRemote } from 'next-mdx-remote'
|
|
import { NextSeo } from 'next-seo'
|
|
import Head from 'next/head'
|
|
import NextImage from 'next/image'
|
|
import Link from 'next/link'
|
|
import { Button } from 'ui'
|
|
import { Image } from 'ui-patterns/Image'
|
|
|
|
import ShareArticleActions from '@/components/Blog/ShareArticleActions'
|
|
import DefaultLayout from '@/components/Layouts/Default'
|
|
import SectionContainer from '@/components/Layouts/SectionContainer'
|
|
import authors from '@/lib/authors.json'
|
|
import { breadcrumbs } from '@/lib/breadcrumbs'
|
|
import { capitalize, isNotNullOrUndefined } from '@/lib/helpers'
|
|
import { breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld'
|
|
import mdxComponents from '@/lib/mdx/mdxComponents'
|
|
import { mdxSerialize } from '@/lib/mdx/mdxSerialize'
|
|
import { getAllPostSlugs, getPostdata } from '@/lib/posts'
|
|
import { useSendTelemetryEvent } from '@/lib/telemetry'
|
|
import type Author from '@/types/author'
|
|
|
|
dayjs.extend(utc)
|
|
dayjs.extend(timezone)
|
|
dayjs.extend(advancedFormat)
|
|
|
|
type EventType = 'webinar' | 'meetup' | 'conference' | 'talk' | 'hackathon' | 'launch_week'
|
|
|
|
type CTA = {
|
|
url: string
|
|
label?: string
|
|
disabled_label?: string
|
|
disabled?: boolean
|
|
target?: '_blank' | '_self'
|
|
}
|
|
|
|
type CompanyType = {
|
|
name: string
|
|
website_url: string
|
|
logo: string
|
|
logo_light: string
|
|
}
|
|
|
|
interface EventData {
|
|
title: string
|
|
subtitle?: string
|
|
main_cta?: CTA
|
|
description: string
|
|
type: EventType
|
|
company?: CompanyType
|
|
onDemand?: boolean
|
|
disable_page_build?: boolean
|
|
duration?: string
|
|
timezone?: string
|
|
tags?: string[]
|
|
date: string
|
|
end_date?: string
|
|
speakers: string
|
|
speakers_label?: string
|
|
partners?: string
|
|
og_image?: string
|
|
thumb?: string
|
|
thumb_light?: string
|
|
youtubeHero?: string
|
|
author_url?: string
|
|
launchweek?: number | string
|
|
meta_title?: string
|
|
meta_description?: string
|
|
video?: string
|
|
}
|
|
|
|
type EventPageProps = {
|
|
event: Event & EventData
|
|
}
|
|
|
|
type MatterReturn = {
|
|
data: EventData
|
|
content: string
|
|
}
|
|
|
|
type Event = {
|
|
slug: string
|
|
source: string
|
|
content: any
|
|
}
|
|
|
|
type Params = {
|
|
slug: string
|
|
}
|
|
|
|
export async function getStaticPaths() {
|
|
const paths = getAllPostSlugs('_events')
|
|
return {
|
|
paths,
|
|
fallback: false,
|
|
}
|
|
}
|
|
|
|
export const getStaticProps: GetStaticProps<EventPageProps, Params> = async ({ params }) => {
|
|
if (params?.slug === undefined) {
|
|
throw new Error('Missing slug for pages/event/[slug].tsx')
|
|
}
|
|
|
|
const filePath = `${params.slug}`
|
|
const postContent = await getPostdata(filePath, '_events')
|
|
const { data, content } = matter(postContent) as unknown as MatterReturn
|
|
|
|
if (data.disable_page_build) {
|
|
return {
|
|
notFound: true,
|
|
}
|
|
}
|
|
|
|
const mdxSource: any = await mdxSerialize(content)
|
|
|
|
return {
|
|
props: {
|
|
event: {
|
|
slug: `${params.slug}`,
|
|
source: content,
|
|
...data,
|
|
content: mdxSource,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
const EventPage = ({ event }: InferGetStaticPropsType<typeof getStaticProps>) => {
|
|
const content = event.content
|
|
const speakersArray = event.speakers?.split(',')
|
|
const speakers = speakersArray
|
|
?.map((speakerId: string) => {
|
|
return authors.find((author) => author.author_id === speakerId)
|
|
})
|
|
.filter(isNotNullOrUndefined) as Author[]
|
|
const partnersArray = event.partners
|
|
?.split(',')
|
|
.map((p: string) => p.trim())
|
|
.filter(Boolean)
|
|
const hadEndDate = event.end_date?.length
|
|
|
|
const IS_REGISTRATION_OPEN =
|
|
event.onDemand ||
|
|
(hadEndDate ? Date.parse(event.end_date!) > Date.now() : Date.parse(event.date) > Date.now())
|
|
|
|
const ogImageUrl = event.og_image
|
|
? event.og_image
|
|
: encodeURI(
|
|
`${process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:54321' : 'https://obuldanrptloktxcffvn.supabase.co'}/functions/v1/og-images?site=events&eventType=${event.type}&title=${event.meta_title ?? event.title}&description=${event.meta_description ?? event.description}&date=${dayjs(event.date).tz(event.timezone).format(`DD MMM YYYY`)}&duration=${event.duration}`
|
|
)
|
|
|
|
const meta = {
|
|
title: `${event.meta_title ?? event.title} | ${dayjs(event.date)
|
|
.tz(event.timezone)
|
|
.format(
|
|
hadEndDate ? `DD` : `DD MMM YYYY`
|
|
)}${hadEndDate ? dayjs(event.end_date).tz(event.timezone).format(` - DD MMM`) : ''} | ${capitalize(event.type)}`,
|
|
description: event.meta_description ?? event.description,
|
|
url: `https://supabase.com/events/${event.slug}`,
|
|
image: ogImageUrl,
|
|
}
|
|
|
|
const eventIcons = {
|
|
conference: (props: any) => <VideoCameraIcon {...props} />,
|
|
hackathon: (props: any) => <DesktopComputerIcon {...props} />,
|
|
launch_week: (props: any) => <VideoCameraIcon {...props} />,
|
|
meetup: (props: any) => <HandIcon {...props} />,
|
|
talk: (props: any) => <MicrophoneIcon {...props} />,
|
|
webinar: (props: any) => <VideoCameraIcon {...props} />,
|
|
}
|
|
|
|
const Icon = eventIcons[event.type]
|
|
|
|
const sendTelemetryEvent = useSendTelemetryEvent()
|
|
|
|
return (
|
|
<>
|
|
<NextSeo
|
|
title={meta.title}
|
|
description={meta.description}
|
|
openGraph={{
|
|
title: meta.title,
|
|
description: meta.description,
|
|
url: meta.url,
|
|
type: 'article',
|
|
images: [
|
|
{
|
|
url: meta.image,
|
|
alt: `${event.title} thumbnail`,
|
|
width: 1200,
|
|
height: 627,
|
|
},
|
|
],
|
|
videos: event.video
|
|
? [
|
|
{
|
|
// youtube based video meta
|
|
url: event.video,
|
|
type: 'application/x-shockwave-flash',
|
|
width: 640,
|
|
height: 385,
|
|
},
|
|
]
|
|
: undefined,
|
|
article: {
|
|
publishedTime: event.date,
|
|
tags: event.tags?.map((cat: string) => {
|
|
return cat
|
|
}),
|
|
},
|
|
}}
|
|
/>
|
|
<Head>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{
|
|
__html: serializeJsonLd(
|
|
breadcrumbListSchema([
|
|
...breadcrumbs.eventsIndex,
|
|
{
|
|
name: event.meta_title ?? event.title,
|
|
url: `https://supabase.com/events/${event.slug}`,
|
|
},
|
|
])
|
|
),
|
|
}}
|
|
/>
|
|
</Head>
|
|
<DefaultLayout>
|
|
<div className="flex flex-col w-full bg-alternative border-b border-muted">
|
|
<SectionContainer className="py-2! flex items-start">
|
|
<Link
|
|
href="/events"
|
|
className="text-foreground-lighter hover:text-foreground flex m-0! p-0! leading-3! gap-1 cursor-pointer items-center text-sm transition"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
All Events
|
|
</Link>
|
|
</SectionContainer>
|
|
</div>
|
|
|
|
<div className="flex flex-col w-full">
|
|
<header className="relative bg-alternative w-full overflow-hidden">
|
|
<NextImage
|
|
src="/images/events/events-bg-dark.svg"
|
|
alt=""
|
|
fill
|
|
sizes="100%"
|
|
className="not-sr-only hidden dark:block w-full h-full absolute inset-0 object-cover object-bottom"
|
|
/>
|
|
<NextImage
|
|
src="/images/events/events-bg-light.svg"
|
|
alt=""
|
|
fill
|
|
sizes="100%"
|
|
className="not-sr-only block dark:hidden w-full h-full absolute inset-0 object-cover object-bottom"
|
|
/>
|
|
<SectionContainer
|
|
className="
|
|
relative z-10
|
|
lg:min-h-[400px] h-full
|
|
grid grid-cols-1 xl:grid-cols-2
|
|
gap-8
|
|
text-foreground-light
|
|
py-10! md:py-16!
|
|
"
|
|
>
|
|
<div className="h-full flex flex-col justify-between">
|
|
<div className="flex flex-col gap-2 md:gap-3 items-start mb-8">
|
|
<div className="flex flex-row text-sm items-center flex-wrap">
|
|
<Icon className="hidden sm:inline-block w-4 h-4 text-brand mr-2" />
|
|
<span className="uppercase text-brand font-mono">{event.type}</span>
|
|
<span className="mx-3 px-3 border-x">
|
|
{dayjs(event.date).tz(event.timezone).format(`DD MMM YYYY [at] hA z`)}
|
|
</span>
|
|
<span className="">Duration: {event.duration}</span>
|
|
</div>
|
|
|
|
<h1 className="text-foreground text-3xl md:text-4xl xl:pr-9">{event.title}</h1>
|
|
<p>{event.subtitle}</p>
|
|
<Button
|
|
type="primary"
|
|
size="medium"
|
|
className="mt-2"
|
|
disabled={
|
|
!IS_REGISTRATION_OPEN || event.main_cta?.disabled || event.main_cta?.disabled
|
|
}
|
|
asChild
|
|
>
|
|
<Link
|
|
href={event.main_cta?.url ?? '#'}
|
|
target={event.main_cta?.target ? event.main_cta?.target : undefined}
|
|
onClick={() =>
|
|
sendTelemetryEvent({
|
|
action: 'www_pricing_plan_cta_clicked',
|
|
properties: { eventTitle: event.title },
|
|
})
|
|
}
|
|
>
|
|
{IS_REGISTRATION_OPEN
|
|
? event.main_cta?.label
|
|
? event.main_cta?.label
|
|
: 'Register to this event'
|
|
: event.main_cta?.disabled_label
|
|
? event.main_cta?.disabled_label
|
|
: 'Registrations are closed'}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-col text-sm">
|
|
<span>Share on</span>
|
|
<ShareArticleActions title={meta.title} slug={meta.url} basePath="" />
|
|
</div>
|
|
</div>
|
|
{!!event.thumb && (
|
|
<div className="relative w-full aspect-5/3 lg:aspect-3/2 overflow-hidden border shadow-lg rounded-lg z-10">
|
|
<Image
|
|
src={{
|
|
dark: `/images/events/` + event.thumb,
|
|
light:
|
|
`/images/events/` +
|
|
(!!event.thumb_light ? event.thumb_light! : event.thumb),
|
|
}}
|
|
fill
|
|
sizes="100%"
|
|
quality={100}
|
|
containerClassName="
|
|
h-full
|
|
[&.next-image--dynamic-fill_img]:h-full!
|
|
[&.next-image--dynamic-fill_img]:object-cover!
|
|
"
|
|
alt={`${event.title} thumbnail`}
|
|
/>
|
|
</div>
|
|
)}
|
|
</SectionContainer>
|
|
</header>
|
|
<SectionContainer className="grid lg:grid-cols-3 gap-12 py-10! md:py-16!">
|
|
{event.company && (
|
|
<div className="order-first lg:col-span-full flex items-center gap-4 md:gap-6 lg:mb-4">
|
|
<figure className="h-6 [&_.next-image--dynamic-fill_img]:h-full!">
|
|
<Image
|
|
src={{ dark: supabaseLogoWordmarkDark, light: supabaseLogoWordmarkLight }}
|
|
alt="Supabase Logo"
|
|
width={160}
|
|
height={30}
|
|
sizes="100%"
|
|
className="relative! object-contain object-left"
|
|
containerClassName="h-full object-contain object-left rounded-none! border-none!"
|
|
priority
|
|
/>
|
|
</figure>
|
|
<XIcon className="w-4 h-4 text-foreground-lighter" />
|
|
<Link
|
|
href={event.company?.website_url ?? '#'}
|
|
target="_blank"
|
|
className="h-5 aspect-9/1 transition-opacity opacity-100 hover:opacity-90 [&_.next-image--dynamic-fill_img]:h-full!"
|
|
>
|
|
<Image
|
|
src={{ dark: event.company?.logo, light: event.company?.logo_light }}
|
|
alt={`${event.company?.name} Logo`}
|
|
width={160}
|
|
height={30}
|
|
sizes="100%"
|
|
className="relative! object-contain object-left"
|
|
containerClassName="h-full object-contain object-left rounded-none! border-none!"
|
|
priority
|
|
/>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
<main className="lg:col-span-2">
|
|
<div className="prose prose-docs">
|
|
<h2 className="text-foreground-light text-sm font-mono uppercase">
|
|
About this event
|
|
</h2>
|
|
<MDXRemote {...content} components={mdxComponents()} />
|
|
</div>
|
|
<aside className="mt-8">
|
|
<Button
|
|
type="primary"
|
|
size="medium"
|
|
className="mt-2"
|
|
disabled={!IS_REGISTRATION_OPEN || event.main_cta?.disabled}
|
|
asChild
|
|
>
|
|
<Link
|
|
href={event.main_cta?.url ?? '#'}
|
|
aria-disabled={!IS_REGISTRATION_OPEN}
|
|
target={event.main_cta?.target ? event.main_cta?.target : undefined}
|
|
>
|
|
{IS_REGISTRATION_OPEN
|
|
? event.main_cta?.label
|
|
? event.main_cta?.label
|
|
: 'Register now'
|
|
: event.main_cta?.disabled_label
|
|
? event.main_cta?.disabled_label
|
|
: 'Registrations are closed'}
|
|
</Link>
|
|
</Button>
|
|
</aside>
|
|
</main>
|
|
<aside className="order-first lg:order-last">
|
|
{partnersArray && partnersArray.length > 0 && (
|
|
<div className="flex flex-col gap-4 mb-8">
|
|
<h2 className="text-foreground-light text-sm font-mono uppercase">Partners</h2>
|
|
<ul className="list-none flex flex-row flex-wrap gap-6 items-center">
|
|
{partnersArray.map((partner) => (
|
|
<li key={partner}>
|
|
<NextImage
|
|
src={`/images/logos/publicity/${partner}.svg`}
|
|
alt={`${partner} logo`}
|
|
width={80}
|
|
height={24}
|
|
className="object-contain"
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{speakers && (
|
|
<div className="flex flex-col gap-4">
|
|
<h2 className="text-foreground-light text-sm font-mono uppercase">
|
|
{event.speakers_label ?? 'Speakers'}
|
|
</h2>
|
|
<ul className="list-none flex flex-col md:flex-row flex-wrap lg:flex-col gap-4 md:gap-8 lg:gap-4">
|
|
{speakers?.map((speaker) => (
|
|
<li key={speaker?.author_id}>
|
|
<Link href={speaker.author_url} target="_blank" className="flex gap-4">
|
|
<div className="relative ring-background w-10 h-10 md:w-12 md:h-12 rounded-full ring-2 cursor-pointer">
|
|
{speaker?.author_image_url && (
|
|
<NextImage
|
|
src={speaker.author_image_url}
|
|
className="rounded-full object-cover border border-default w-full h-full"
|
|
alt={`${speaker.author} avatar`}
|
|
width={100}
|
|
height={100}
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<p>{speaker?.author}</p>
|
|
<span className="text-xs text-foreground-light">
|
|
{speaker?.position}
|
|
{speaker?.company ? `, ${speaker?.company}` : ', Supabase'}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
</SectionContainer>
|
|
</div>
|
|
</DefaultLayout>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default EventPage
|