Files
supabase/apps/docs/lib/breadcrumbs.ts
Pamela Chia 5ce163fd69 feat(docs): add BreadcrumbList JSON-LD to guide pages (#45477)
## 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 -->
2026-05-07 12:09:41 +08:00

77 lines
2.1 KiB
TypeScript

import * as NavItems from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
export interface BreadcrumbItem {
name?: string
title?: string
url?: string
}
const SECTION_PATH_TO_KEY: Record<string, keyof typeof NavItems> = {
ai: 'ai',
api: 'api',
auth: 'auth',
contributing: 'contributing',
cron: 'cron',
database: 'database',
deployment: 'deployment',
functions: 'functions',
'getting-started': 'gettingstarted',
graphql: 'graphql',
integrations: 'integrations',
'local-development': 'local_development',
platform: 'platform',
queues: 'queues',
realtime: 'realtime',
resources: 'resources',
security: 'security',
'self-hosting': 'self_hosting',
storage: 'storage',
telemetry: 'telemetry',
}
function getSectionMenu(pathname: string) {
const trimmed = pathname.replace(/^\/guides\/?/, '')
const top = trimmed.split('/')[0]
const key = SECTION_PATH_TO_KEY[top] ?? 'gettingstarted'
return (NavItems as Record<string, any>)[key]
}
function findMenuItemByUrl(
menu: any,
targetUrl: string,
parents: BreadcrumbItem[] = []
): BreadcrumbItem[] | null {
if (menu.items) {
for (const item of menu.items) {
const result = findMenuItemByUrl(item, targetUrl, [...parents, menu])
if (result) return result
}
}
if (menu.url === targetUrl) {
return [...parents, menu]
}
return null
}
export function resolveBreadcrumbs(pathname: string): BreadcrumbItem[] {
if (pathname.startsWith('/guides/troubleshooting')) {
return [{ name: 'Troubleshooting', url: '/guides/troubleshooting' }]
}
if (pathname.startsWith('/guides/getting-started/ai-prompts')) {
return [
{ name: 'Getting started', url: '/guides/getting-started' },
{ name: 'AI Tools' },
{ name: 'Prompts', url: '/guides/getting-started/ai-prompts' },
]
}
if (pathname.startsWith('/guides/getting-started/ai-skills')) {
return [
{ name: 'Getting started', url: '/guides/getting-started' },
{ name: 'AI Tools' },
{ name: 'Agent Skills', url: '/guides/getting-started/ai-skills' },
]
}
const menu = getSectionMenu(pathname)
return findMenuItemByUrl(menu, pathname) ?? []
}