diff --git a/src/components/LibraryLayout.tsx b/src/components/LibraryLayout.tsx index 56ab39461..40eba3fb7 100644 --- a/src/components/LibraryLayout.tsx +++ b/src/components/LibraryLayout.tsx @@ -1,3 +1,5 @@ +import { Breadcrumbs } from './Breadcrumbs' +import { getLibraryBreadcrumbItems } from '~/utils/library-breadcrumbs' import * as React from 'react' import { CaretLeftIcon } from '@phosphor-icons/react/CaretLeft' import { CaretRightIcon } from '@phosphor-icons/react/CaretRight' @@ -816,6 +818,10 @@ export function LibraryLayout({ const { _splat, version: routeVersion } = useParams({ strict: false }) const matches = useMatches() const lastMatch = last(matches) + const landingBreadcrumbItems = + lastMatch.status === 'success' && lastMatch.routeId.endsWith('/$version/') + ? getLibraryBreadcrumbItems(getLibrary(libraryId)) + : undefined const version = getLibraryLayoutVersion({ layoutVersion, pathname: lastMatch.pathname, @@ -1545,6 +1551,14 @@ export function LibraryLayout({ 'mx-auto w-[var(--docs-content-width,900px)]', )} > + {landingBreadcrumbItems && ( +
+ +
+ )} {children} diff --git a/src/routes/-library-landing-route.tsx b/src/routes/-library-landing-route.tsx index cfba7f7b3..e85111c74 100644 --- a/src/routes/-library-landing-route.tsx +++ b/src/routes/-library-landing-route.tsx @@ -1,3 +1,4 @@ +import { getLibraryBreadcrumbJsonLd } from '~/utils/library-breadcrumbs' import { redirect } from '@tanstack/react-router' import type { QueryClient } from '@tanstack/react-query' import { getLibrary } from '~/libraries' @@ -59,10 +60,22 @@ export async function loadLibraryLandingRouteData( } } -export function getLibraryLandingHead(libraryId: LandingLibraryId) { +export function getLibraryLandingHead( + libraryId: LandingLibraryId, + isSuccess: boolean, +) { const library = getLibrary(libraryId) + const breadcrumb = isSuccess ? getLibraryBreadcrumbJsonLd(library) : undefined return { + scripts: breadcrumb + ? [ + { + type: 'application/ld+json', + children: JSON.stringify(breadcrumb).replaceAll('<', '\\u003c'), + }, + ] + : [], meta: seo({ title: libraryId === 'start' diff --git a/src/routes/_library/ai.$version.index.tsx b/src/routes/_library/ai.$version.index.tsx index eacc1ce68..21108c7eb 100644 --- a/src/routes/_library/ai.$version.index.tsx +++ b/src/routes/_library/ai.$version.index.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute('/_library/ai/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('ai', params.version, queryClient), - head: () => getLibraryLandingHead('ai'), + head: ({ match }) => getLibraryLandingHead('ai', match.status === 'success'), headers: () => getLibraryLandingHeaders('ai'), component: AiLandingRoute, }) diff --git a/src/routes/_library/charts.$version.index.tsx b/src/routes/_library/charts.$version.index.tsx index b1b61cfdc..002613091 100644 --- a/src/routes/_library/charts.$version.index.tsx +++ b/src/routes/_library/charts.$version.index.tsx @@ -28,7 +28,8 @@ export const Route = createFileRoute('/_library/charts/$version/')({ return { ...landingData, catalogOrderSeed, catalog } }, - head: () => getLibraryLandingHead('charts'), + head: ({ match }) => + getLibraryLandingHead('charts', match.status === 'success'), headers: () => getLibraryLandingHeaders('charts'), component: ChartsLandingRoute, }) diff --git a/src/routes/_library/cli.$version.index.tsx b/src/routes/_library/cli.$version.index.tsx index 96def1bd3..c563ac912 100644 --- a/src/routes/_library/cli.$version.index.tsx +++ b/src/routes/_library/cli.$version.index.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute('/_library/cli/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('cli', params.version, queryClient), - head: () => getLibraryLandingHead('cli'), + head: ({ match }) => getLibraryLandingHead('cli', match.status === 'success'), headers: () => getLibraryLandingHeaders('cli'), component: CliLandingRoute, }) diff --git a/src/routes/_library/config.$version.index.tsx b/src/routes/_library/config.$version.index.tsx index 68abb4455..5c0343fb9 100644 --- a/src/routes/_library/config.$version.index.tsx +++ b/src/routes/_library/config.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/config/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('config', params.version, queryClient), - head: () => getLibraryLandingHead('config'), + head: ({ match }) => + getLibraryLandingHead('config', match.status === 'success'), headers: () => getLibraryLandingHeaders('config'), component: ConfigLandingRoute, }) diff --git a/src/routes/_library/db.$version.index.tsx b/src/routes/_library/db.$version.index.tsx index e6357d434..507f138e2 100644 --- a/src/routes/_library/db.$version.index.tsx +++ b/src/routes/_library/db.$version.index.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute('/_library/db/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('db', params.version, queryClient), - head: () => getLibraryLandingHead('db'), + head: ({ match }) => getLibraryLandingHead('db', match.status === 'success'), headers: () => getLibraryLandingHeaders('db'), component: DbLandingRoute, }) diff --git a/src/routes/_library/devtools.$version.index.tsx b/src/routes/_library/devtools.$version.index.tsx index 499c813fb..88c83b80e 100644 --- a/src/routes/_library/devtools.$version.index.tsx +++ b/src/routes/_library/devtools.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/devtools/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('devtools', params.version, queryClient), - head: () => getLibraryLandingHead('devtools'), + head: ({ match }) => + getLibraryLandingHead('devtools', match.status === 'success'), headers: () => getLibraryLandingHeaders('devtools'), component: DevtoolsLandingRoute, }) diff --git a/src/routes/_library/form.$version.index.tsx b/src/routes/_library/form.$version.index.tsx index 24222375f..b014d2d56 100644 --- a/src/routes/_library/form.$version.index.tsx +++ b/src/routes/_library/form.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/form/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('form', params.version, queryClient), - head: () => getLibraryLandingHead('form'), + head: ({ match }) => + getLibraryLandingHead('form', match.status === 'success'), headers: () => getLibraryLandingHeaders('form'), component: FormLandingRoute, }) diff --git a/src/routes/_library/highlight.$version.index.tsx b/src/routes/_library/highlight.$version.index.tsx index a3a0ef240..49194aa54 100644 --- a/src/routes/_library/highlight.$version.index.tsx +++ b/src/routes/_library/highlight.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/highlight/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('highlight', params.version, queryClient), - head: () => getLibraryLandingHead('highlight'), + head: ({ match }) => + getLibraryLandingHead('highlight', match.status === 'success'), headers: () => getLibraryLandingHeaders('highlight'), component: HighlightLandingRoute, }) diff --git a/src/routes/_library/hotkeys.$version.index.tsx b/src/routes/_library/hotkeys.$version.index.tsx index 4eabcda08..519ee2b98 100644 --- a/src/routes/_library/hotkeys.$version.index.tsx +++ b/src/routes/_library/hotkeys.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/hotkeys/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('hotkeys', params.version, queryClient), - head: () => getLibraryLandingHead('hotkeys'), + head: ({ match }) => + getLibraryLandingHead('hotkeys', match.status === 'success'), headers: () => getLibraryLandingHeaders('hotkeys'), component: HotkeysLandingRoute, }) diff --git a/src/routes/_library/intent.$version.index.tsx b/src/routes/_library/intent.$version.index.tsx index 3ea1193c0..9be47a0b7 100644 --- a/src/routes/_library/intent.$version.index.tsx +++ b/src/routes/_library/intent.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/intent/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('intent', params.version, queryClient), - head: () => getLibraryLandingHead('intent'), + head: ({ match }) => + getLibraryLandingHead('intent', match.status === 'success'), headers: () => getLibraryLandingHeaders('intent'), component: IntentLandingRoute, }) diff --git a/src/routes/_library/markdown.$version.index.tsx b/src/routes/_library/markdown.$version.index.tsx index 6b1834a74..1481fc0d6 100644 --- a/src/routes/_library/markdown.$version.index.tsx +++ b/src/routes/_library/markdown.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/markdown/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('markdown', params.version, queryClient), - head: () => getLibraryLandingHead('markdown'), + head: ({ match }) => + getLibraryLandingHead('markdown', match.status === 'success'), headers: () => getLibraryLandingHeaders('markdown'), component: MarkdownLandingRoute, }) diff --git a/src/routes/_library/pacer.$version.index.tsx b/src/routes/_library/pacer.$version.index.tsx index 3d7a3ac32..bfeb4498e 100644 --- a/src/routes/_library/pacer.$version.index.tsx +++ b/src/routes/_library/pacer.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/pacer/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('pacer', params.version, queryClient), - head: () => getLibraryLandingHead('pacer'), + head: ({ match }) => + getLibraryLandingHead('pacer', match.status === 'success'), headers: () => getLibraryLandingHeaders('pacer'), component: PacerLandingRoute, }) diff --git a/src/routes/_library/query.$version.index.tsx b/src/routes/_library/query.$version.index.tsx index 987d8126e..8d79dc2ec 100644 --- a/src/routes/_library/query.$version.index.tsx +++ b/src/routes/_library/query.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/query/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('query', params.version, queryClient), - head: () => getLibraryLandingHead('query'), + head: ({ match }) => + getLibraryLandingHead('query', match.status === 'success'), headers: () => getLibraryLandingHeaders('query'), component: QueryLandingRoute, }) diff --git a/src/routes/_library/ranger.$version.index.tsx b/src/routes/_library/ranger.$version.index.tsx index f0a8cede6..4476c46f4 100644 --- a/src/routes/_library/ranger.$version.index.tsx +++ b/src/routes/_library/ranger.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/ranger/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('ranger', params.version, queryClient), - head: () => getLibraryLandingHead('ranger'), + head: ({ match }) => + getLibraryLandingHead('ranger', match.status === 'success'), headers: () => getLibraryLandingHeaders('ranger'), component: RangerLandingRoute, }) diff --git a/src/routes/_library/redact.$version.index.tsx b/src/routes/_library/redact.$version.index.tsx index ce5b9acf3..779d8233b 100644 --- a/src/routes/_library/redact.$version.index.tsx +++ b/src/routes/_library/redact.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/redact/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('redact', params.version, queryClient), - head: () => getLibraryLandingHead('redact'), + head: ({ match }) => + getLibraryLandingHead('redact', match.status === 'success'), headers: () => getLibraryLandingHeaders('redact'), component: RedactLandingRoute, }) diff --git a/src/routes/_library/router.$version.index.tsx b/src/routes/_library/router.$version.index.tsx index 176ea8a74..6b015ae8c 100644 --- a/src/routes/_library/router.$version.index.tsx +++ b/src/routes/_library/router.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/router/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('router', params.version, queryClient), - head: () => getLibraryLandingHead('router'), + head: ({ match }) => + getLibraryLandingHead('router', match.status === 'success'), headers: () => getLibraryLandingHeaders('router'), component: RouterLandingRoute, }) diff --git a/src/routes/_library/start.$version.index.tsx b/src/routes/_library/start.$version.index.tsx index 670dd461f..a2388208d 100644 --- a/src/routes/_library/start.$version.index.tsx +++ b/src/routes/_library/start.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/start/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('start', params.version, queryClient), - head: () => getLibraryLandingHead('start'), + head: ({ match }) => + getLibraryLandingHead('start', match.status === 'success'), headers: () => getLibraryLandingHeaders('start'), component: StartLandingRoute, }) diff --git a/src/routes/_library/store.$version.index.tsx b/src/routes/_library/store.$version.index.tsx index e2fed0eaf..1658d7478 100644 --- a/src/routes/_library/store.$version.index.tsx +++ b/src/routes/_library/store.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/store/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('store', params.version, queryClient), - head: () => getLibraryLandingHead('store'), + head: ({ match }) => + getLibraryLandingHead('store', match.status === 'success'), headers: () => getLibraryLandingHeaders('store'), component: StoreLandingRoute, }) diff --git a/src/routes/_library/table.$version.index.tsx b/src/routes/_library/table.$version.index.tsx index c68796601..ce86e63f4 100644 --- a/src/routes/_library/table.$version.index.tsx +++ b/src/routes/_library/table.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/table/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('table', params.version, queryClient), - head: () => getLibraryLandingHead('table'), + head: ({ match }) => + getLibraryLandingHead('table', match.status === 'success'), headers: () => getLibraryLandingHeaders('table'), component: TableLandingRoute, }) diff --git a/src/routes/_library/virtual.$version.index.tsx b/src/routes/_library/virtual.$version.index.tsx index fa727d38d..8ae1526bc 100644 --- a/src/routes/_library/virtual.$version.index.tsx +++ b/src/routes/_library/virtual.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/virtual/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('virtual', params.version, queryClient), - head: () => getLibraryLandingHead('virtual'), + head: ({ match }) => + getLibraryLandingHead('virtual', match.status === 'success'), headers: () => getLibraryLandingHeaders('virtual'), component: VirtualLandingRoute, }) diff --git a/src/routes/_library/workflow.$version.index.tsx b/src/routes/_library/workflow.$version.index.tsx index 8efe9cc89..289508cb1 100644 --- a/src/routes/_library/workflow.$version.index.tsx +++ b/src/routes/_library/workflow.$version.index.tsx @@ -15,7 +15,8 @@ export const Route = createFileRoute('/_library/workflow/$version/')({ }, loader: ({ params, context: { queryClient } }) => loadLibraryLandingRouteData('workflow', params.version, queryClient), - head: () => getLibraryLandingHead('workflow'), + head: ({ match }) => + getLibraryLandingHead('workflow', match.status === 'success'), headers: () => getLibraryLandingHeaders('workflow'), component: WorkflowLandingRoute, }) diff --git a/src/utils/library-breadcrumbs.ts b/src/utils/library-breadcrumbs.ts new file mode 100644 index 000000000..2e75e352a --- /dev/null +++ b/src/utils/library-breadcrumbs.ts @@ -0,0 +1,29 @@ +import type { BreadcrumbItem } from '~/components/Breadcrumbs' +import { canonicalUrl } from './seo' + +type LibraryBreadcrumbData = { name: string; to?: string; visible?: boolean } + +export function getLibraryBreadcrumbItems( + library: LibraryBreadcrumbData, +): Array | undefined { + if (library.visible === false || !library.to) return undefined + return [ + { label: 'Libraries', to: '/libraries' }, + { label: library.name, to: library.to }, + ] +} + +export function getLibraryBreadcrumbJsonLd(library: LibraryBreadcrumbData) { + const items = getLibraryBreadcrumbItems(library) + if (!items) return undefined + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: items.map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.label, + item: canonicalUrl(item.to), + })), + } +} diff --git a/tests/library-breadcrumbs.test.ts b/tests/library-breadcrumbs.test.ts new file mode 100644 index 000000000..855e4f704 --- /dev/null +++ b/tests/library-breadcrumbs.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { query, workflow } from '../src/libraries' +import { + getLibraryBreadcrumbItems, + getLibraryBreadcrumbJsonLd, +} from '../src/utils/library-breadcrumbs' +Object.assign(globalThis, { __TANSTACK_SITE_URL__: 'https://tanstack.com' }) + +test('public library landing trail shares labels and canonical destinations', () => { + assert.deepEqual(getLibraryBreadcrumbItems(query), [ + { label: 'Libraries', to: '/libraries' }, + { label: 'TanStack Query', to: '/query/latest' }, + ]) + assert.deepEqual(getLibraryBreadcrumbJsonLd(query)?.itemListElement, [ + { + '@type': 'ListItem', + position: 1, + name: 'Libraries', + item: 'https://tanstack.com/libraries', + }, + { + '@type': 'ListItem', + position: 2, + name: 'TanStack Query', + item: 'https://tanstack.com/query/latest', + }, + ]) +}) +test('hidden library emits neither a visible trail nor structured data', () => { + assert.equal(getLibraryBreadcrumbItems(workflow), undefined) + assert.equal(getLibraryBreadcrumbJsonLd(workflow), undefined) +}) + +test('library without a landing destination has no breadcrumb trail', () => { + assert.equal( + getLibraryBreadcrumbItems({ name: 'Unlinked library' }), + undefined, + ) + assert.equal( + getLibraryBreadcrumbJsonLd({ name: 'Unlinked library' }), + undefined, + ) +})