fix(docs): always emit BreadcrumbList item field (#45744)

## Summary

Eliminates the Google Search Console "Missing field 'item' (in
'itemListElement')" critical error on 230 `/docs/guides/*` pages. The
schema was emitting `ListItem`s without an `item` field for intermediate
category nodes that lack a URL in the docs nav. Per [Google's
spec](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb),
`item` is required on every BreadcrumbList position except the last leaf
— so url-less items are filtered out instead.

Also fixes a smaller quality gap surfaced during preview verification:
the `auth` section root in `NavigationMenu.constants.ts` was missing a
`url`, so auth trails were dropping the "Auth" breadcrumb level (`Docs >
Guides > JSON Web Tokens (JWT) > Overview` instead of `Docs > Guides >
Auth > JSON Web Tokens (JWT) > Overview`). Every other section root
already has a `url`; auth was the lone outlier.


## Testing

Tested locally via vitest (`pnpm --filter docs exec vitest run
lib/json-ld.test.ts`):
- [x] All-urls chain: every `itemListElement` has string `item` and
`name`
- [x] Leaf-url-mismatch: leaf uses `pathname` even when the chain leaf
URL differs
- [x] All-url-less chain: returns `null`
- [x] Empty chain: returns `null`

Tested on the preview deploy against 7 representative GSC-flagged paths:
- [x] `/docs/guides/getting-started/ai-prompts` — 4 positions, 0 missing
- [x] `/docs/guides/getting-started/ai-skills` — 4 positions, 0 missing
- [x] `/docs/guides/auth/jwts` — 4 positions, 0 missing (after auth fix:
includes "Auth")
- [x] `/docs/guides/auth/social-login/auth-google` — 4 positions, 0
missing (after auth fix: includes "Auth")
- [x] `/docs/guides/database/postgres-js` — 4 positions, 0 missing
- [x] `/docs/guides/storage/quickstart` — 4 positions, 0 missing
- [x]
`/docs/guides/platform/migrating-within-supabase/dashboard-restore` — 5
positions, 0 missing

Post-merge:
- [ ] validator.schema.org against deployed URL: 0 errors
- [ ] GSC "Validate fix" on the breadcrumb issue (1-2 week re-crawl
window)

## Linear

- fixes GROWTH-835

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved breadcrumb validation to filter incomplete entries and avoid
broken documentation links.
* Restored root link for the Auth navigation section so the Auth menu
item now navigates to /guides/auth.

* **Tests**
* Added comprehensive tests covering breadcrumb generation and edge
cases.

* **Refactor**
* Streamlined breadcrumb JSON‑LD schema generation for clearer output
and maintainability.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45744)

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pamela Chia authored and GitHub committed 2026-05-11 12:57:22 +08:00
1 parent dc0cc42d14
commit 5a5099adba
4 files changed
+95 -14

No files matched your search

@@ -684,6 +684,7 @@ export const PhoneLoginsItems = [
export const auth: NavMenuConstant = {
icon: 'auth',
title: 'Auth',
url: '/guides/auth',
items: [
{
name: 'Overview',
@@ -71,10 +71,8 @@ const GuideTemplate = ({
}: GuideTemplateProps) => {
const hideToc = meta?.hideToc || meta?.hide_table_of_contents
const breadcrumbChain = resolveBreadcrumbs(pathname)
const breadcrumbJsonLd =
breadcrumbChain.length > 0
? serializeJsonLd(breadcrumbListSchema({ pathname, chain: breadcrumbChain }))
: null
const breadcrumbSchema = breadcrumbListSchema({ pathname, chain: breadcrumbChain })
const breadcrumbJsonLd = breadcrumbSchema ? serializeJsonLd(breadcrumbSchema) : null
return (
<TocAnchorsProvider>
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { breadcrumbListSchema } from './json-ld'
describe('breadcrumbListSchema', () => {
let warn: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warn.mockRestore()
})
it('emits item and name on every position when all chain items have urls', () => {
const result = breadcrumbListSchema({
pathname: '/guides/auth/jwts',
chain: [
{ name: 'Authentication', url: '/guides/auth' },
{ name: 'JWTs', url: '/guides/auth/jwts' },
],
})
expect(result).not.toBeNull()
expect(result!.itemListElement).toHaveLength(4)
for (const entry of result!.itemListElement) {
expect(typeof entry.item).toBe('string')
expect(entry.item).toMatch(/^https?:\/\//)
expect(typeof entry.name).toBe('string')
}
})
it('uses pathname for the leaf url even when chain leaf url differs', () => {
const result = breadcrumbListSchema({
pathname: '/guides/database/postgres-js',
chain: [{ name: 'Postgres.js', url: '/guides/database/postgres-js-old' }],
})
expect(result).not.toBeNull()
const leaf = result!.itemListElement.at(-1)
expect(leaf?.item).toMatch(/\/guides\/database\/postgres-js$/)
})
it('returns null when every chain item is url-less', () => {
const result = breadcrumbListSchema({
pathname: '/guides/some-broken-route',
chain: [{ name: 'Category A' }, { name: 'Category B' }],
})
expect(result).toBeNull()
})
it('returns null on an empty chain', () => {
const result = breadcrumbListSchema({ pathname: '/guides', chain: [] })
expect(result).toBeNull()
})
})
+33 -10
View File
@@ -10,31 +10,54 @@ export function serializeJsonLd(schema: JsonLdSchema): string {
.replace(/&/g, '\\u0026')
}
const DOCS_ROOT: BreadcrumbItem = { name: 'Docs', url: '' }
const GUIDES_ROOT: BreadcrumbItem = { name: 'Guides', url: '/guides' }
type ValidCrumb = BreadcrumbItem & { url: string }
const DOCS_ROOT: ValidCrumb = { name: 'Docs', url: '' }
const GUIDES_ROOT: ValidCrumb = { name: 'Guides', url: '/guides' }
interface BreadcrumbListSchemaInput {
pathname: string
chain: BreadcrumbItem[]
}
const warnedPaths = new Set<string>()
function isValidCrumb(crumb: BreadcrumbItem): crumb is ValidCrumb {
return crumb.url !== undefined && Boolean(crumb.title ?? crumb.name)
}
export function breadcrumbListSchema({ pathname, chain }: BreadcrumbListSchemaInput) {
const fullChain: BreadcrumbItem[] = [DOCS_ROOT, GUIDES_ROOT, ...chain]
const filteredChain = chain.filter(isValidCrumb)
if (
process.env.NODE_ENV !== 'production' &&
filteredChain.length !== chain.length &&
!warnedPaths.has(pathname)
) {
warnedPaths.add(pathname)
const dropped = chain
.filter((crumb) => !isValidCrumb(crumb))
.map((crumb) => crumb.title ?? crumb.name ?? '<unnamed>')
console.warn(
`[json-ld] Dropping breadcrumb items missing url or name from ${pathname}: ${dropped.join(', ')}`
)
}
if (filteredChain.length === 0) return null
const fullChain: ValidCrumb[] = [DOCS_ROOT, GUIDES_ROOT, ...filteredChain]
const itemListElement = fullChain.map((crumb, index) => {
const isLeaf = index === fullChain.length - 1
const name = crumb.title ?? crumb.name
const path = isLeaf ? pathname : crumb.url
const itemUrl = path === '' ? PROD_URL : `${PROD_URL}${path}`
const listItem: Record<string, unknown> = {
return {
'@type': 'ListItem',
position: index + 1,
name,
name: crumb.title ?? crumb.name,
item: itemUrl,
}
if (path !== undefined) {
listItem.item = path === '' ? PROD_URL : `${PROD_URL}${path}`
}
return listItem
})
return {