diff --git a/src/routes/_library/$libraryId/$version.docs.$.tsx b/src/routes/_library/$libraryId/$version.docs.$.tsx index 8de53a1ef..dbde4a2f3 100644 --- a/src/routes/_library/$libraryId/$version.docs.$.tsx +++ b/src/routes/_library/$libraryId/$version.docs.$.tsx @@ -1,4 +1,5 @@ import { canonicalUrl, seo } from '~/utils/seo' +import { getDocsStructuredData } from '~/utils/docs-structured-data' import { ogImageUrl } from '~/utils/og' import { Doc } from '~/components/Doc' import { @@ -96,7 +97,24 @@ export const Route = createFileRoute('/_library/$libraryId/$version/docs/$')({ appendPathToDocsHref({ docsPath: docsPath ?? '', libraryId, version }), ) + const structuredData = getDocsStructuredData({ + doc: loaderData, + library, + canonicalHref, + }) + return { + scripts: structuredData + ? [ + { + type: 'application/ld+json', + children: JSON.stringify(structuredData).replaceAll( + '<', + '\\u003c', + ), + }, + ] + : [], meta: [ ...seo({ title: loaderData?.title diff --git a/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx b/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx index 1b9846c35..98a02f9dc 100644 --- a/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx +++ b/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx @@ -6,6 +6,7 @@ import { createFileRoute, } from '@tanstack/react-router' import { canonicalUrl, seo } from '~/utils/seo' +import { getDocsStructuredData } from '~/utils/docs-structured-data' import { ogImageUrl } from '~/utils/og' import { Doc } from '~/components/Doc' import { @@ -101,7 +102,24 @@ export const Route = createFileRoute( }), ) + const structuredData = getDocsStructuredData({ + doc: ctx.loaderData, + library, + canonicalHref, + }) + return { + scripts: structuredData + ? [ + { + type: 'application/ld+json', + children: JSON.stringify(structuredData).replaceAll( + '<', + '\\u003c', + ), + }, + ] + : [], meta: [ ...seo({ title: ctx.loaderData?.title diff --git a/src/utils/docs-structured-data.ts b/src/utils/docs-structured-data.ts new file mode 100644 index 000000000..8fdcdb68e --- /dev/null +++ b/src/utils/docs-structured-data.ts @@ -0,0 +1,69 @@ +import type { readDocsFreshness } from './docs-freshness' +import { + getTanStackOrganizationJsonLd, + TANSTACK_ORGANIZATION_ID, +} from './organization-structured-data' +import { canonicalUrl } from './seo' + +export function getDocsStructuredData({ + doc, + library, + canonicalHref, +}: { + doc: + | { + title: string + description?: string + freshness?: ReturnType + } + | undefined + library: { id: string; name: string; visible?: boolean } + canonicalHref: string +}) { + if (!doc?.title || library.visible === false) return undefined + + return { + '@context': 'https://schema.org', + '@graph': [ + getTanStackOrganizationJsonLd(), + { + '@type': 'WebPage', + '@id': canonicalHref, + url: canonicalHref, + name: doc.title, + ...(doc.description ? { description: doc.description } : {}), + breadcrumb: { '@id': `${canonicalHref}#breadcrumb` }, + mainEntity: { '@id': `${canonicalHref}#article` }, + }, + { + '@type': 'TechArticle', + '@id': `${canonicalHref}#article`, + headline: doc.title, + ...(doc.description ? { description: doc.description } : {}), + mainEntityOfPage: { '@id': canonicalHref }, + publisher: { '@id': TANSTACK_ORGANIZATION_ID }, + ...(doc.freshness?.updated + ? { dateModified: doc.freshness.updated } + : {}), + }, + { + '@type': 'BreadcrumbList', + '@id': `${canonicalHref}#breadcrumb`, + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: library.name, + item: canonicalUrl(`/${library.id}/latest`), + }, + { + '@type': 'ListItem', + position: 2, + name: doc.title, + item: canonicalHref, + }, + ], + }, + ], + } +} diff --git a/tests/docs-structured-data.test.ts b/tests/docs-structured-data.test.ts new file mode 100644 index 000000000..8f32a94a5 --- /dev/null +++ b/tests/docs-structured-data.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { getDocsStructuredData } from '../src/utils/docs-structured-data' +import { readDocsFreshness } from '../src/utils/docs-freshness' +import { TANSTACK_ORGANIZATION_ID } from '../src/utils/organization-structured-data' + +const library = { id: 'start', name: 'TanStack Start' } +const canonicalHref = + 'https://tanstack.com/start/latest/docs/framework/react/overview' + +test('document entities and breadcrumbs use the resolved canonical URL', () => { + const data = getDocsStructuredData({ + library, + canonicalHref, + doc: { title: 'Overview', description: 'Build a full-stack React app.' }, + }) + assert.ok(data) + const graph = data['@graph'] + const page = graph.find((node) => node['@type'] === 'WebPage') + const article = graph.find((node) => node['@type'] === 'TechArticle') + const breadcrumb = graph.find((node) => node['@type'] === 'BreadcrumbList') + assert.ok(page) + assert.ok(article && 'headline' in article) + assert.ok(breadcrumb && 'itemListElement' in breadcrumb) + assert.equal(page['@id'], canonicalHref) + assert.deepEqual(article.mainEntityOfPage, { '@id': canonicalHref }) + assert.deepEqual(article.publisher, { '@id': TANSTACK_ORGANIZATION_ID }) + assert.equal(article.headline, 'Overview') + assert.equal(article.description, 'Build a full-stack React app.') + assert.deepEqual(breadcrumb.itemListElement, [ + { + '@type': 'ListItem', + position: 1, + name: library.name, + item: 'https://tanstack.com/start/latest', + }, + { + '@type': 'ListItem', + position: 2, + name: 'Overview', + item: canonicalHref, + }, + ]) + for (const field of [ + 'author', + 'datePublished', + 'dateModified', + 'aggregateRating', + ]) { + assert.equal(field in article, false) + } +}) + +test('only validated document freshness supplies a modified date', () => { + for (const [updated, expected] of [ + ['2026-09-11', '2026-09-11'], + ['2026-02-30', undefined], + [undefined, undefined], + ]) { + const data = getDocsStructuredData({ + library, + canonicalHref, + doc: { title: 'Overview', freshness: readDocsFreshness({ updated }) }, + }) + const article = data?.['@graph'].find( + (node) => node['@type'] === 'TechArticle', + ) + assert.ok(article && 'headline' in article) + assert.equal(article.dateModified, expected) + } +}) + +test('missing, untitled, and hidden-library docs emit no structured data', () => { + assert.deepEqual( + getDocsStructuredData({ library, canonicalHref, doc: undefined }), + undefined, + ) + assert.deepEqual( + getDocsStructuredData({ library, canonicalHref, doc: { title: '' } }), + undefined, + ) + assert.deepEqual( + getDocsStructuredData({ + library: { ...library, visible: false }, + canonicalHref, + doc: { title: 'Overview' }, + }), + undefined, + ) +})