Skip to content

Commit eaa2e1e

Browse files
KevinVandyclaude
andcommitted
feat: redirect latest v# to /latest and canonicalize old-version docs
- 308 redirect the latest numbered version (e.g. /query/v5) to /latest, since both serve identical content from the same branch - old-version docs pages emit a rel=canonical to their /latest equivalent when the same doc path exists on the latest branch, checked via the cached docs path manifest (fails open) - version dropdown collapses "Latest" and the latest numbered version into a single "v# (latest)" option Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 797555e commit eaa2e1e

8 files changed

Lines changed: 184 additions & 28 deletions

File tree

src/components/VersionSelect.tsx

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function VersionSelect({ libraryId }: { libraryId: LibraryId }) {
1313
const library = getLibrary(libraryId)
1414
const versionConfig = useVersionConfig({
1515
versions: library.availableVersions,
16+
latestVersion: library.latestVersion,
1617
})
1718
return (
1819
<Select
@@ -85,37 +86,38 @@ function useCurrentVersion(versions: string[]) {
8586
}
8687
}
8788

88-
function useVersionConfig({ versions }: { versions: string[] }) {
89+
function useVersionConfig({
90+
versions,
91+
latestVersion,
92+
}: {
93+
versions: string[]
94+
latestVersion: string
95+
}) {
8996
const currentVersion = useCurrentVersion(versions)
9097

9198
const versionConfig = React.useMemo(() => {
92-
const available = versions.reduce(
93-
(acc: SelectOption[], version) => {
94-
acc.push({
95-
label: version,
96-
value: version,
97-
})
98-
return acc
99-
},
100-
[
101-
{
102-
label: 'Latest',
103-
value: 'latest',
104-
},
105-
],
99+
// The latest numbered version and 'latest' are the same docs, so they
100+
// collapse into a single option that navigates to the /latest URL.
101+
const available = versions.map(
102+
(version): SelectOption =>
103+
version === latestVersion
104+
? { label: `${version} (latest)`, value: 'latest' }
105+
: { label: version, value: version },
106106
)
107107

108+
const isLatest =
109+
currentVersion.version === latestVersion ||
110+
!versions.includes(currentVersion.version)
111+
108112
return {
109113
label: 'Version',
110-
selected: versions.includes(currentVersion.version)
111-
? currentVersion.version
112-
: 'latest',
114+
selected: isLatest ? 'latest' : currentVersion.version,
113115
available,
114116
onSelect: (option: { label: string; value: string }) => {
115117
currentVersion.setVersion(option.value)
116118
},
117119
}
118-
}, [currentVersion, versions])
120+
}, [currentVersion, versions, latestVersion])
119121

120122
return versionConfig
121123
}

src/routes/__root.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,28 @@ type CanonicalHeadMatch = {
7575
staticData?: {
7676
includeSearchInCanonical?: boolean
7777
}
78+
loaderData?: unknown
79+
}
80+
81+
// Loaders can point a page's canonical at a different URL (e.g. old-version
82+
// docs canonicalize to /latest) by returning `canonicalPathOverride`.
83+
function getCanonicalPathOverride(
84+
matches: ReadonlyArray<CanonicalHeadMatch>,
85+
): string | null {
86+
for (let i = matches.length - 1; i >= 0; i--) {
87+
const loaderData = matches[i]?.loaderData
88+
89+
if (
90+
loaderData &&
91+
typeof loaderData === 'object' &&
92+
'canonicalPathOverride' in loaderData &&
93+
typeof loaderData.canonicalPathOverride === 'string'
94+
) {
95+
return loaderData.canonicalPathOverride
96+
}
97+
}
98+
99+
return null
78100
}
79101

80102
function getCanonicalHeadTags(matches: ReadonlyArray<CanonicalHeadMatch>): {
@@ -90,7 +112,9 @@ function getCanonicalHeadTags(matches: ReadonlyArray<CanonicalHeadMatch>): {
90112
includeSearchInCanonical && lastMatch
91113
? defaultStringifySearch(lastMatch.search)
92114
: ''
93-
const preferredCanonicalPath = getCanonicalPath(canonicalPath)
115+
const preferredCanonicalPath = getCanonicalPath(
116+
getCanonicalPathOverride(matches) ?? canonicalPath,
117+
)
94118
const pageUrl = canonicalUrl(
95119
preferredCanonicalPath ?? canonicalPath,
96120
canonicalSearch,

src/routes/_library/$libraryId/$version.docs.$.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { seo } from '~/utils/seo'
22
import { ogImageUrl } from '~/utils/og'
33
import { Doc } from '~/components/Doc'
4-
import { buildDocsRedirectHref, loadDocsRoute } from '~/utils/docs'
4+
import {
5+
appendPathToDocsHref,
6+
buildDocsRedirectHref,
7+
loadDocsRoute,
8+
} from '~/utils/docs'
59
import { findLibrary, getBranch, getLibrary } from '~/libraries'
610
import { DocContainer } from '~/components/DocContainer'
711
import { getDocsCacheHeaders } from '~/utils/docs-cache-headers'
@@ -33,6 +37,7 @@ export const Route = createFileRoute('/_library/$libraryId/$version/docs/$')({
3337
docsPath: requestedDocsPath,
3438
defaultDocs: library.defaultDocs ?? 'overview',
3539
frameworks: library.frameworks,
40+
latestBranch: getBranch(library, 'latest'),
3641
redirectFromPaths: requestedDocsPath ? [requestedDocsPath] : [],
3742
})
3843

@@ -52,7 +57,18 @@ export const Route = createFileRoute('/_library/$libraryId/$version/docs/$')({
5257
throw notFound()
5358
}
5459

55-
return result.doc
60+
return {
61+
...result.doc,
62+
// Old-version pages that still exist on latest canonicalize to /latest
63+
// (read by getCanonicalHeadTags in __root.tsx).
64+
canonicalPathOverride: result.latestDocsPath
65+
? appendPathToDocsHref({
66+
docsPath: result.latestDocsPath,
67+
libraryId,
68+
version: 'latest',
69+
})
70+
: undefined,
71+
}
5672
},
5773
head: ({ loaderData, params }) => {
5874
const { libraryId, version, _splat: docsPath } = params

src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
import { seo } from '~/utils/seo'
88
import { ogImageUrl } from '~/utils/og'
99
import { Doc } from '~/components/Doc'
10-
import { buildDocsRedirectHref, loadDocsRoute } from '~/utils/docs'
10+
import {
11+
appendPathToDocsHref,
12+
buildDocsRedirectHref,
13+
loadDocsRoute,
14+
} from '~/utils/docs'
1115
import { getBranch, getLibrary } from '~/libraries'
1216
import { capitalize } from '~/utils/utils'
1317
import { DocContainer } from '~/components/DocContainer'
@@ -31,6 +35,7 @@ export const Route = createFileRoute(
3135
docsPath: requestedDocsPath,
3236
defaultDocs: library.defaultDocs ?? 'overview',
3337
frameworks: library.frameworks,
38+
latestBranch: getBranch(library, 'latest'),
3439
redirectFromPaths: docsPath
3540
? [requestedDocsPath, `${framework}/${docsPath}`]
3641
: [requestedDocsPath],
@@ -55,7 +60,18 @@ export const Route = createFileRoute(
5560
})
5661
}
5762

58-
return result.doc
63+
return {
64+
...result.doc,
65+
// Old-version pages that still exist on latest canonicalize to /latest
66+
// (read by getCanonicalHeadTags in __root.tsx).
67+
canonicalPathOverride: result.latestDocsPath
68+
? appendPathToDocsHref({
69+
docsPath: result.latestDocsPath,
70+
libraryId,
71+
version: 'latest',
72+
})
73+
: undefined,
74+
}
5975
},
6076
component: Docs,
6177
headers: ({ params }) => {

src/routes/_library/$libraryId/$version.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ export const Route = createFileRoute('/_library/$libraryId/$version')({
2525
})
2626
})
2727

28+
// The latest numbered version (e.g. /query/v5) serves the exact same
29+
// content as /latest; permanently redirect so only one URL gets indexed.
30+
if (version === library.latestVersion) {
31+
throw redirect({
32+
href: ctx.location.href.replace(
33+
`/${libraryId}/${version}`,
34+
`/${libraryId}/latest`,
35+
),
36+
statusCode: 308,
37+
})
38+
}
39+
2840
library.handleRedirects?.(ctx.location.href)
2941
},
3042
loader: async (ctx) => {

src/utils/docs-redirects.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ export function resolveDocsPathRedirect({
7777
return { type: 'not-found' }
7878
}
7979

80+
export function docsManifestHasPath(
81+
manifest: DocsRedirectManifest,
82+
docsPath: string,
83+
) {
84+
const normalizedPath = normalizeDocsPath(docsPath)
85+
86+
if (normalizedPath === null) {
87+
return false
88+
}
89+
90+
return manifest.paths.some(
91+
(path) => normalizeManifestPath(path) === normalizedPath,
92+
)
93+
}
94+
8095
export function appendPathToDocsHref(opts: {
8196
docsPath: string
8297
libraryId: string

src/utils/docs.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
fetchRepoDirectoryContents,
99
} from './docs.functions'
1010
import {
11+
appendPathToDocsHref,
1112
buildDocsMarkdownRedirectHref,
1213
buildDocsRedirectHref,
14+
docsManifestHasPath,
1315
resolveDocsPathRedirect,
1416
type DocsPathResolution,
1517
} from './docs-redirects'
@@ -161,6 +163,7 @@ export type LoadDocsRouteResult =
161163
type: 'loaded'
162164
docsPath: string
163165
doc: Awaited<ReturnType<typeof loadDocs>>
166+
latestDocsPath: string | null
164167
}
165168
| {
166169
type: 'redirect'
@@ -176,6 +179,7 @@ export async function loadDocsRoute(opts: {
176179
docsPath: string
177180
docsRoot: string
178181
frameworks: Array<string>
182+
latestBranch?: string
179183
redirectFromPaths: Array<string>
180184
repo: string
181185
}): Promise<LoadDocsRouteResult> {
@@ -186,15 +190,21 @@ export async function loadDocsRoute(opts: {
186190
}
187191

188192
try {
189-
return {
190-
type: 'loaded',
191-
docsPath: resolution.docsPath,
192-
doc: await loadDocs({
193+
const [doc, latestDocsPath] = await Promise.all([
194+
loadDocs({
193195
repo: opts.repo,
194196
branch: opts.branch,
195197
docsRoot: opts.docsRoot,
196198
docsPath: resolution.docsPath,
197199
}),
200+
findLatestDocsPath(opts, resolution.docsPath),
201+
])
202+
203+
return {
204+
type: 'loaded',
205+
docsPath: resolution.docsPath,
206+
doc,
207+
latestDocsPath,
198208
}
199209
} catch (error) {
200210
if (!isDocsNotFoundError(error)) {
@@ -214,6 +224,39 @@ export async function loadDocsRoute(opts: {
214224
}
215225
}
216226

227+
/**
228+
* When serving an old version, checks whether the same doc path exists on the
229+
* latest branch so the page can canonicalize to its /latest equivalent.
230+
* Fails open (null) so a manifest hiccup never breaks the doc itself.
231+
*/
232+
async function findLatestDocsPath(
233+
opts: {
234+
branch: string
235+
docsRoot: string
236+
latestBranch?: string
237+
repo: string
238+
},
239+
docsPath: string,
240+
): Promise<string | null> {
241+
if (!opts.latestBranch || opts.latestBranch === opts.branch) {
242+
return null
243+
}
244+
245+
try {
246+
const manifest = await fetchDocsPathManifest({
247+
data: {
248+
repo: opts.repo,
249+
branch: opts.latestBranch,
250+
docsRoot: opts.docsRoot,
251+
},
252+
})
253+
254+
return docsManifestHasPath(manifest, docsPath) ? docsPath : null
255+
} catch {
256+
return null
257+
}
258+
}
259+
217260
async function resolveDocsRoutePathWithRedirects(opts: {
218261
branch: string
219262
defaultDocs: string
@@ -278,6 +321,7 @@ export async function resolveDocsRedirect(opts: {
278321
}
279322

280323
export {
324+
appendPathToDocsHref,
281325
buildDocsMarkdownRedirectHref,
282326
buildDocsRedirectHref,
283327
fetchFile,

tests/docs-redirects.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
22
import {
33
buildDocsMarkdownRedirectHref,
44
buildDocsRedirectHref,
5+
docsManifestHasPath,
56
resolveDocsPathRedirect,
67
type DocsRedirectManifest,
78
} from '../src/utils/docs-redirects'
@@ -208,4 +209,30 @@ assert.equal(
208209
'https://tanstack.com/query/v5/docs/framework/react/overview.md?pm=pnpm#motivation',
209210
)
210211

212+
assert.equal(
213+
docsManifestHasPath(
214+
manifestWithPaths(['guides/queries.md', 'framework/react/overview.md']),
215+
'guides/queries',
216+
),
217+
true,
218+
)
219+
220+
assert.equal(
221+
docsManifestHasPath(
222+
manifestWithPaths(['guides/queries/index.md']),
223+
'guides/queries',
224+
),
225+
true,
226+
)
227+
228+
assert.equal(
229+
docsManifestHasPath(
230+
manifestWithPaths(['guides/queries.md']),
231+
'guides/removed-in-latest',
232+
),
233+
false,
234+
)
235+
236+
assert.equal(docsManifestHasPath(manifestWithPaths(['overview.md']), ''), false)
237+
211238
console.log('docs redirect tests passed')

0 commit comments

Comments
 (0)