From 5a5099adba7a5c25cf09d6b58cd8eb734fda4a55 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 11 May 2026 12:57:22 +0800 Subject: [PATCH] fix(docs): always emit BreadcrumbList `item` field (#45744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ## 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) --- .../NavigationMenu.constants.ts | 1 + .../docs/features/docs/GuidesMdx.template.tsx | 6 +- apps/docs/lib/json-ld.test.ts | 59 +++++++++++++++++++ apps/docs/lib/json-ld.ts | 43 ++++++++++---- 4 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 apps/docs/lib/json-ld.test.ts diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 8e72a2538ce..1da27c51790 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -684,6 +684,7 @@ export const PhoneLoginsItems = [ export const auth: NavMenuConstant = { icon: 'auth', title: 'Auth', + url: '/guides/auth', items: [ { name: 'Overview', diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index d66ae606a5b..c3d5a5e2c3c 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -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 ( diff --git a/apps/docs/lib/json-ld.test.ts b/apps/docs/lib/json-ld.test.ts new file mode 100644 index 00000000000..08e9a56a084 --- /dev/null +++ b/apps/docs/lib/json-ld.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { breadcrumbListSchema } from './json-ld' + +describe('breadcrumbListSchema', () => { + let warn: ReturnType + + 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() + }) +}) diff --git a/apps/docs/lib/json-ld.ts b/apps/docs/lib/json-ld.ts index 0dc58618ce6..839fd92b49c 100644 --- a/apps/docs/lib/json-ld.ts +++ b/apps/docs/lib/json-ld.ts @@ -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() + +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 ?? '') + 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 = { + 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 {