mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
## Summary Emits `BreadcrumbList` JSON-LD on every `/docs/guides/*` page served by `GuideTemplate`. Search engines and AI crawlers get an explicit hierarchical signal for the docs site (the marketing site already shipped JSON-LD via #45451). The chain prepends `Docs > Guides` to the existing resolver output, so a page like `/docs/guides/auth/passwords` produces a 5-level chain with the leaf URL set per Google's spec. ## Changes - New `apps/docs/lib/breadcrumbs.ts`: pure pathname → chain resolver, server-safe. Extracted from the existing client `useBreadcrumbs` hook so the same logic runs in both contexts. - New `apps/docs/lib/json-ld.ts`: `serializeJsonLd` + `breadcrumbListSchema` mirroring `apps/www/lib/json-ld.ts`. - `Breadcrumbs.tsx` (visual) now delegates to the shared resolver — single source of truth for visual + SEO chains. - `GuideTemplate` takes a required `pathname` prop and emits `<script type="application/ld+json">` next to `<Breadcrumbs />`. Skipped when the chain is empty (e.g., page not in nav menu). Middle items without URLs (e.g., the "Auth" section root) omit `item`, matching the visual breadcrumb. - 8 explicit-prop callers updated; `[[...slug]]` callers already spread `data` (which carries `pathname`). ## Scope **Out of scope:** - `/docs/reference/*` (SDK reference) — no breadcrumbs rendered today, would need separate traversal over spec JSON. - `/guides/troubleshooting/*` — uses its own template, not `GuideTemplate`. - `TechArticle` per-page schema — high maintenance for marginal value. ## Testing (Vercel preview) ```bash curl -s https://<preview>/docs/guides/auth/passwords | grep -oE '<script type="application/ld\+json"[^>]*>[^<]+</script>' ``` Expect a script tag with the chain `Docs > Guides > Auth > Flows (How-tos) > Password-based`, leaf URL `https://supabase.com/docs/guides/auth/passwords`. - [x] `/docs/guides/auth/passwords` — 5-item chain, leaf URL present - [x] `/docs/guides/getting-started/features` — 4-item chain, all items have URLs - [x] `/docs/guides/getting-started/ai-prompts/<slug>` — special-case chain (`Getting started > AI Tools > Prompts > <slug>`), leaf URL falls back to pathname - [x] `/docs/guides/database/database-advisors` (explicit-prop caller) — chain renders - [x] Visual breadcrumb on the same pages still renders correctly - [ ] Validate output through [Google Rich Results Test](https://search.google.com/test/rich-results) on a deployed preview URL - [x] `/docs/guides/troubleshooting/<slug>` — no JSON-LD emitted (different template, intentional) ## Linear - fixes GROWTH-820 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added JSON-LD breadcrumb markup to guide pages to improve search/discovery. * **Improvements** * Centralized breadcrumb generation for consistent, accurate breadcrumbs across guides. * Multiple guide pages updated to ensure breadcrumbs and page context display correctly. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
161 lines
5.3 KiB
TypeScript
161 lines
5.3 KiB
TypeScript
'use client'
|
|
|
|
import { resolveBreadcrumbs } from '~/lib/breadcrumbs'
|
|
import { useBreakpoint } from 'common'
|
|
import Link from 'next/link'
|
|
import { usePathname, useSearchParams } from 'next/navigation'
|
|
import React, { Fragment, Suspense } from 'react'
|
|
import {
|
|
Breadcrumb_Shadcn_ as Breadcrumb,
|
|
BreadcrumbEllipsis_Shadcn_ as BreadcrumbEllipsis,
|
|
BreadcrumbItem_Shadcn_ as BreadcrumbItem,
|
|
BreadcrumbLink_Shadcn_ as BreadcrumbLink,
|
|
BreadcrumbList_Shadcn_ as BreadcrumbList,
|
|
BreadcrumbPage_Shadcn_ as BreadcrumbPage,
|
|
BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator,
|
|
Button,
|
|
cn,
|
|
Drawer,
|
|
DrawerClose,
|
|
DrawerContent,
|
|
DrawerFooter,
|
|
DrawerTrigger,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from 'ui'
|
|
|
|
interface BreadcrumbsProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
minLength?: number
|
|
forceDisplayOnMobile?: boolean
|
|
}
|
|
|
|
export default function Breadcrumbs(props: BreadcrumbsProps) {
|
|
return (
|
|
<Suspense>
|
|
<BreadcrumbsInternal {...props} />
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
const BreadcrumbsInternal = ({
|
|
className,
|
|
minLength = 2,
|
|
forceDisplayOnMobile = false,
|
|
}: BreadcrumbsProps) => {
|
|
const breadcrumbs = useBreadcrumbs()
|
|
const [open, setOpen] = React.useState(false)
|
|
const isMobile = useBreakpoint('md')
|
|
|
|
const ITEMS_TO_DISPLAY = isMobile ? 4 : 3
|
|
|
|
if (!breadcrumbs?.length || breadcrumbs?.length < minLength) return null
|
|
|
|
const appendedBreadcrumbs = breadcrumbs?.slice(
|
|
-ITEMS_TO_DISPLAY + 1,
|
|
isMobile && !forceDisplayOnMobile ? -1 : undefined
|
|
)
|
|
|
|
return (
|
|
<Breadcrumb className={cn(className)}>
|
|
<BreadcrumbList className="text-foreground-lighter p-0">
|
|
{breadcrumbs.length >= ITEMS_TO_DISPLAY && (
|
|
<>
|
|
<BreadcrumbItem>
|
|
{breadcrumbs[0].url ? (
|
|
<BreadcrumbLink asChild>
|
|
<Link href={breadcrumbs[0].url}>
|
|
{breadcrumbs[0].title || breadcrumbs[0].name}
|
|
</Link>
|
|
</BreadcrumbLink>
|
|
) : (
|
|
<BreadcrumbPage>{breadcrumbs[0].title || breadcrumbs[0].name}</BreadcrumbPage>
|
|
)}
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator />
|
|
</>
|
|
)}
|
|
{breadcrumbs.length > ITEMS_TO_DISPLAY && (
|
|
<>
|
|
<BreadcrumbItem>
|
|
{!isMobile ? (
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger className="flex items-center gap-1" aria-label="Toggle menu">
|
|
<BreadcrumbEllipsis className="h-4 w-4" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
{breadcrumbs.slice(1, -2).map((crumb, index) => (
|
|
<DropdownMenuItem
|
|
key={index}
|
|
className={cn(!crumb.url && 'pointer-events-none')}
|
|
>
|
|
{crumb.url ? (
|
|
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
|
|
) : (
|
|
crumb.title || crumb.name
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<Drawer open={open} onOpenChange={setOpen}>
|
|
<DrawerTrigger aria-label="Toggle Menu">
|
|
<BreadcrumbEllipsis className="h-4 w-4" />
|
|
</DrawerTrigger>
|
|
<DrawerContent>
|
|
<div className="grid gap-1 px-4">
|
|
{breadcrumbs.slice(1, -2).map((crumb, index) =>
|
|
crumb.url ? (
|
|
<Link key={index} href={crumb.url}>
|
|
{crumb.title || crumb.name}
|
|
</Link>
|
|
) : (
|
|
crumb.title || crumb.name
|
|
)
|
|
)}
|
|
</div>
|
|
<DrawerFooter className="pt-4">
|
|
<DrawerClose asChild>
|
|
<Button type="outline">Close</Button>
|
|
</DrawerClose>
|
|
</DrawerFooter>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
)}
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator />
|
|
</>
|
|
)}
|
|
{appendedBreadcrumbs?.map((crumb, index) => (
|
|
<Fragment key={index}>
|
|
<BreadcrumbItem
|
|
className={cn(
|
|
'flex items-center overflow-hidden',
|
|
index === appendedBreadcrumbs.length - 1 && 'md:text-foreground-light'
|
|
)}
|
|
>
|
|
{crumb.url ? (
|
|
<BreadcrumbLink asChild>
|
|
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
|
|
</BreadcrumbLink>
|
|
) : (
|
|
<BreadcrumbPage>{crumb.title || crumb.name}</BreadcrumbPage>
|
|
)}
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator
|
|
className={cn(index === appendedBreadcrumbs.length - 1 && 'md:hidden')}
|
|
/>
|
|
</Fragment>
|
|
))}
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
)
|
|
}
|
|
|
|
function useBreadcrumbs() {
|
|
const pathname = usePathname()
|
|
return resolveBreadcrumbs(pathname)
|
|
}
|