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 {