From 94ad5c0d71349b29a19cf36ff2fe6b1f89595ec8 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 27 Jun 2026 12:09:31 -0600 Subject: [PATCH] Fix legacy docs URL redirects --- src/routes/__root.tsx | 14 +- .../_library/$libraryId/$version.docs.$.tsx | 56 +++-- .../$version.docs.framework.$framework.$.tsx | 68 +++--- ...ion.docs.framework.$framework.{$}[.]md.tsx | 36 ++- .../$libraryId/$version.docs.{$}[.]md.tsx | 31 ++- src/utils/docs-redirects.ts | 198 +++++++++++++++++ src/utils/docs.functions.ts | 54 ++++- src/utils/docs.ts | 206 +++++++++++++++++- src/utils/documents.server.ts | 12 +- tests/docs-redirects.test.ts | 200 +++++++++++++++++ tests/docs-route-smoke.test.ts | 119 ++++++++++ 11 files changed, 891 insertions(+), 103 deletions(-) create mode 100644 src/utils/docs-redirects.ts create mode 100644 tests/docs-redirects.test.ts create mode 100644 tests/docs-route-smoke.test.ts diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 348f538f5..0306c9e24 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { createRootRouteWithContext, - redirect, useMatches, useRouterState, HeadContent, @@ -141,18 +140,7 @@ class OptionalDevtoolsBoundary extends React.Component< export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ - loader: (ctx) => { - if ( - ctx.location.href.match(/\/docs\/(react|vue|angular|svelte|solid)\//gm) - ) { - throw redirect({ - href: ctx.location.href.replace( - /\/docs\/(react|vue|angular|svelte|solid)\//gm, - '/docs/framework/$1/', - ), - }) - } - + loader: () => { return { partnerPlacementSessionSeed: createPartnerPlacementSessionSeed(), } diff --git a/src/routes/_library/$libraryId/$version.docs.$.tsx b/src/routes/_library/$libraryId/$version.docs.$.tsx index c55fcfc5f..8432bd81b 100644 --- a/src/routes/_library/$libraryId/$version.docs.$.tsx +++ b/src/routes/_library/$libraryId/$version.docs.$.tsx @@ -1,7 +1,7 @@ import { seo } from '~/utils/seo' import { ogImageUrl } from '~/utils/og' import { Doc } from '~/components/Doc' -import { loadDocs, resolveDocsRedirect } from '~/utils/docs' +import { buildDocsRedirectHref, loadDocsRoute } from '~/utils/docs' import { findLibrary, getBranch, getLibrary } from '~/libraries' import { DocContainer } from '~/components/DocContainer' import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' @@ -10,7 +10,6 @@ import { redirect, useLocation, useMatch, - isNotFound, createFileRoute, } from '@tanstack/react-router' @@ -26,39 +25,34 @@ export const Route = createFileRoute('/_library/$libraryId/$version/docs/$')({ const branch = getBranch(library, version) const docsRoot = library.docsRoot || 'docs' + const requestedDocsPath = docsPath ?? '' + const result = await loadDocsRoute({ + repo: library.repo, + branch, + docsRoot, + docsPath: requestedDocsPath, + defaultDocs: library.defaultDocs ?? 'overview', + frameworks: library.frameworks, + redirectFromPaths: requestedDocsPath ? [requestedDocsPath] : [], + }) - try { - return await loadDocs({ - repo: library.repo, - branch, - docsRoot, - docsPath: docsPath ?? '', + if (result.type === 'redirect') { + throw redirect({ + href: buildDocsRedirectHref({ + baseHref: ctx.location.href, + docsPath: result.docsPath, + libraryId, + version, + }), + statusCode: 308, }) - } catch (error) { - const isNotFoundError = - isNotFound(error) || - (error && typeof error === 'object' && 'isNotFound' in error) - - if (isNotFoundError) { - const redirectPath = await resolveDocsRedirect({ - repo: library.repo, - branch, - docsRoot, - docsPaths: docsPath ? [docsPath] : [], - }) - - if (redirectPath !== null) { - throw redirect({ - href: `/${libraryId}/${version}/docs${redirectPath ? `/${redirectPath}` : ''}`, - statusCode: 308, - }) - } - - throw notFound() - } + } - throw error + if (result.type === 'not-found') { + throw notFound() } + + return result.doc }, head: ({ loaderData, params }) => { const { libraryId, version, _splat: docsPath } = params diff --git a/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx b/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx index eb04afbb5..8ddb1b8ac 100644 --- a/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx +++ b/src/routes/_library/$libraryId/$version.docs.framework.$framework.$.tsx @@ -1,5 +1,4 @@ import { - isNotFound, redirect, useLocation, useMatch, @@ -8,7 +7,7 @@ import { import { seo } from '~/utils/seo' import { ogImageUrl } from '~/utils/og' import { Doc } from '~/components/Doc' -import { loadDocs, resolveDocsRedirect } from '~/utils/docs' +import { buildDocsRedirectHref, loadDocsRoute } from '~/utils/docs' import { getBranch, getLibrary } from '~/libraries' import { capitalize } from '~/utils/utils' import { DocContainer } from '~/components/DocContainer' @@ -24,46 +23,39 @@ export const Route = createFileRoute( const library = getLibrary(libraryId) const branch = getBranch(library, version) const docsRoot = library.docsRoot || 'docs' + const requestedDocsPath = `framework/${framework}/${docsPath ?? ''}` + const result = await loadDocsRoute({ + repo: library.repo, + branch, + docsRoot, + docsPath: requestedDocsPath, + defaultDocs: library.defaultDocs ?? 'overview', + frameworks: library.frameworks, + redirectFromPaths: docsPath + ? [requestedDocsPath, `${framework}/${docsPath}`] + : [requestedDocsPath], + }) - try { - return await loadDocs({ - repo: library.repo, - branch, - docsRoot, - docsPath: `framework/${framework}/${docsPath ?? ''}`, + if (result.type === 'redirect') { + throw redirect({ + href: buildDocsRedirectHref({ + baseHref: ctx.location.href, + docsPath: result.docsPath, + libraryId, + version, + }), + statusCode: 308, }) - } catch (error) { - // If doc not found, redirect to framework docs root instead of showing 404 - // This handles cases like switching frameworks where the same doc path doesn't exist - // Check both isNotFound() and the serialized form from server functions - const isNotFoundError = - isNotFound(error) || - (error && typeof error === 'object' && 'isNotFound' in error) - - if (isNotFoundError) { - const redirectPath = await resolveDocsRedirect({ - repo: library.repo, - branch, - docsRoot, - docsPaths: docsPath - ? [`framework/${framework}/${docsPath}`, `${framework}/${docsPath}`] - : [], - }) - - if (redirectPath !== null) { - throw redirect({ - href: `/${libraryId}/${version}/docs${redirectPath ? `/${redirectPath}` : ''}`, - statusCode: 308, - }) - } + } - throw redirect({ - to: '/$libraryId/$version/docs/framework/$framework', - params: { libraryId, version, framework }, - }) - } - throw error + if (result.type === 'not-found') { + throw redirect({ + to: '/$libraryId/$version/docs/framework/$framework', + params: { libraryId, version, framework }, + }) } + + return result.doc }, component: Docs, headers: ({ params }) => { diff --git a/src/routes/_library/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx b/src/routes/_library/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx index 9655bdb4a..7d28abad4 100644 --- a/src/routes/_library/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx +++ b/src/routes/_library/$libraryId/$version.docs.framework.$framework.{$}[.]md.tsx @@ -1,5 +1,5 @@ import { findLibrary, getBranch } from '~/libraries' -import { loadDocs } from '~/utils/docs' +import { buildDocsMarkdownRedirectHref, loadDocsRoute } from '~/utils/docs' import { notFound, createFileRoute } from '@tanstack/react-router' import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' import { getContentDispositionHeader } from '~/utils/http-response' @@ -36,14 +36,38 @@ export const Route = createFileRoute( const root = library.docsRoot || 'docs' const cacheHeaders = getDocsCacheHeaders({ libraryId, version }) - - const doc = await loadDocs({ + const branch = getBranch(library, version) + const requestedDocsPath = `framework/${framework}/${docsPath}` + const result = await loadDocsRoute({ repo: library.repo, - branch: getBranch(library, version), + branch, docsRoot: root, - docsPath: `framework/${framework}/${docsPath}`, + docsPath: requestedDocsPath, + defaultDocs: library.defaultDocs ?? 'overview', + frameworks: library.frameworks, + redirectFromPaths: docsPath + ? [requestedDocsPath, `${framework}/${docsPath}`] + : [requestedDocsPath], }) + if (result.type === 'redirect') { + return Response.redirect( + buildDocsMarkdownRedirectHref({ + requestUrl: request.url, + docsPath: result.docsPath, + libraryId, + version, + }), + 308, + ) + } + + if (result.type === 'not-found') { + throw notFound() + } + + const doc = result.doc + // Filter framework-specific content using framework from URL path const filteredContent = filterFrameworkContent(doc.content, { framework, @@ -52,7 +76,7 @@ export const Route = createFileRoute( }) const markdownContent = `# ${doc.title}\n${filteredContent}` - const filename = `${docsPath || 'file'}.md` + const filename = `${result.docsPath || 'file'}.md` return new Response(markdownContent, { headers: { diff --git a/src/routes/_library/$libraryId/$version.docs.{$}[.]md.tsx b/src/routes/_library/$libraryId/$version.docs.{$}[.]md.tsx index f29922786..309a89967 100644 --- a/src/routes/_library/$libraryId/$version.docs.{$}[.]md.tsx +++ b/src/routes/_library/$libraryId/$version.docs.{$}[.]md.tsx @@ -1,6 +1,6 @@ import { createFileRoute, notFound } from '@tanstack/react-router' import { findLibrary, getBranch } from '~/libraries' -import { loadDocs } from '~/utils/docs' +import { buildDocsMarkdownRedirectHref, loadDocsRoute } from '~/utils/docs' import { getDocsCacheHeaders } from '~/utils/docs-cache-headers' import { getContentDispositionHeader } from '~/utils/http-response' import { filterFrameworkContent } from '~/utils/markdown/filterFrameworkContent' @@ -32,14 +32,35 @@ export const Route = createFileRoute( const root = library.docsRoot || 'docs' const cacheHeaders = getDocsCacheHeaders({ libraryId, version }) - - const doc = await loadDocs({ + const branch = getBranch(library, version) + const result = await loadDocsRoute({ repo: library.repo, - branch: getBranch(library, version), + branch, docsRoot: root, docsPath, + defaultDocs: library.defaultDocs ?? 'overview', + frameworks: library.frameworks, + redirectFromPaths: docsPath ? [docsPath] : [], }) + if (result.type === 'redirect') { + return Response.redirect( + buildDocsMarkdownRedirectHref({ + requestUrl: request.url, + docsPath: result.docsPath, + libraryId, + version, + }), + 308, + ) + } + + if (result.type === 'not-found') { + throw notFound() + } + + const doc = result.doc + // Filter framework-specific content only if framework is explicitly specified const filteredContent = framework ? filterFrameworkContent(doc.content, { @@ -50,7 +71,7 @@ export const Route = createFileRoute( : doc.content const markdownContent = `# ${doc.title}\n${filteredContent}` - const filename = `${docsPath || 'file'}.md` + const filename = `${result.docsPath || 'file'}.md` return new Response(markdownContent, { headers: { diff --git a/src/utils/docs-redirects.ts b/src/utils/docs-redirects.ts new file mode 100644 index 000000000..e87b006a1 --- /dev/null +++ b/src/utils/docs-redirects.ts @@ -0,0 +1,198 @@ +import { isValidRepoPath } from './repo-path' +import { removeLeadingSlash } from './utils' + +export type DocsRedirectManifest = { + paths: Array + redirects: Record +} + +export type DocsPathResolution = + | { + type: 'render' + docsPath: string + } + | { + type: 'redirect' + docsPath: string + } + | { + type: 'not-found' + } + +type ResolveDocsPathRedirectOptions = { + defaultDocs: string + docsPath: string + frameworks: Array + manifest: DocsRedirectManifest +} + +export function resolveDocsPathRedirect({ + defaultDocs, + docsPath, + frameworks, + manifest, +}: ResolveDocsPathRedirectOptions): DocsPathResolution { + const requestedPath = normalizeDocsPath(docsPath) + + if (requestedPath === null) { + return { type: 'not-found' } + } + + const knownPaths = new Set(manifest.paths.map(normalizeManifestPath)) + + if (knownPaths.has(requestedPath)) { + return { type: 'render', docsPath: requestedPath } + } + + const redirectFromTarget = getRedirectTarget({ + knownPaths, + manifest, + requestedPath, + }) + + if (redirectFromTarget !== null) { + return { type: 'redirect', docsPath: redirectFromTarget } + } + + const frameworkRedirectTarget = getFrameworkRedirectTarget({ + defaultDocs, + frameworks, + knownPaths, + requestedPath, + }) + + if (frameworkRedirectTarget !== null) { + return { type: 'redirect', docsPath: frameworkRedirectTarget } + } + + return { type: 'not-found' } +} + +export function appendPathToDocsHref(opts: { + docsPath: string + libraryId: string + version: string +}) { + const pathSuffix = opts.docsPath ? `/${opts.docsPath}` : '' + return `/${opts.libraryId}/${opts.version}/docs${pathSuffix}` +} + +export function buildDocsRedirectHref(opts: { + baseHref: string + docsPath: string + libraryId: string + version: string +}) { + const targetPathname = appendPathToDocsHref(opts) + const url = new URL(opts.baseHref, 'https://tanstack.com') + url.pathname = targetPathname + return `${url.pathname}${url.search}${url.hash}` +} + +export function buildDocsMarkdownRedirectHref(opts: { + docsPath: string + libraryId: string + requestUrl: string + version: string +}) { + const targetPathname = `${appendPathToDocsHref(opts)}.md` + const url = new URL(opts.requestUrl) + url.pathname = targetPathname + return url.href +} + +function getRedirectTarget(opts: { + knownPaths: Set + manifest: DocsRedirectManifest + requestedPath: string +}) { + const targetPath = normalizeDocsPath( + opts.manifest.redirects[opts.requestedPath], + ) + + if ( + targetPath === null || + targetPath === opts.requestedPath || + !opts.knownPaths.has(targetPath) + ) { + return null + } + + return targetPath +} + +function getFrameworkRedirectTarget(opts: { + defaultDocs: string + frameworks: Array + knownPaths: Set + requestedPath: string +}) { + const parts = opts.requestedPath.split('/') + const [firstSegment, secondSegment, ...remainingSegments] = parts + const supportedFrameworks = new Set(opts.frameworks) + + if (firstSegment === 'framework' && secondSegment) { + if (!supportedFrameworks.has(secondSegment)) { + return null + } + + const restPath = remainingSegments.join('/') + return getExistingCandidate(opts.knownPaths, [ + restPath, + restPath === 'overview' ? opts.defaultDocs : null, + ]) + } + + if (!firstSegment || !supportedFrameworks.has(firstSegment)) { + return null + } + + const restPath = [secondSegment, ...remainingSegments] + .filter(Boolean) + .join('/') + const frameworkPath = restPath + ? `framework/${firstSegment}/${restPath}` + : null + + return getExistingCandidate(opts.knownPaths, [ + frameworkPath, + restPath, + restPath === 'overview' ? opts.defaultDocs : null, + ]) +} + +function getExistingCandidate( + knownPaths: Set, + candidates: Array, +) { + for (const candidate of candidates) { + const normalizedCandidate = normalizeDocsPath(candidate) + + if (normalizedCandidate && knownPaths.has(normalizedCandidate)) { + return normalizedCandidate + } + } + + return null +} + +function normalizeManifestPath(path: string) { + return removeLeadingSlash(path.trim()) + .replace(/\.md$/, '') + .replace(/\/index$/, '') + .replace(/\/+$/g, '') +} + +function normalizeDocsPath(path: string | null | undefined) { + if (typeof path !== 'string') { + return null + } + + const normalizedPath = normalizeManifestPath(path) + + if (!normalizedPath || !isValidRepoPath(normalizedPath)) { + return null + } + + return normalizedPath +} diff --git a/src/utils/docs.functions.ts b/src/utils/docs.functions.ts index 1578d199a..0e399cd90 100644 --- a/src/utils/docs.functions.ts +++ b/src/utils/docs.functions.ts @@ -15,16 +15,14 @@ import { getCachedDocsArtifact } from './github-content-cache.server' import { buildRedirectManifest, type RedirectManifestEntry } from './redirects' import { isValidRepoPath, MAX_REPO_PATH_LENGTH } from './repo-path' import { removeLeadingSlash } from './utils' +import type { DocsRedirectManifest } from './docs-redirects' type DocsTreeNode = { path: string children?: Array } -type DocsManifest = { - paths: Array - redirects: Record -} +type DocsManifest = DocsRedirectManifest type RepoFileRequest = { repo: string @@ -212,6 +210,34 @@ async function buildDocsManifest({ } } +async function buildDocsPathManifest({ + repo, + branch, + docsRoot, +}: { + repo: string + branch: string + docsRoot: string +}): Promise { + const nodes = await fetchApiContents(repo, branch, docsRoot) + + if (!nodes) { + return { paths: [], redirects: {} } + } + + const paths = flattenDocsNodes(nodes) + .filter((node) => node.path.endsWith('.md')) + .flatMap((node) => { + const canonicalPath = getCanonicalDocsPath(node.path, docsRoot) + return canonicalPath === null ? [] : [canonicalPath] + }) + + return { + paths, + redirects: {}, + } +} + export const fetchDocsManifest = createServerFn({ method: 'GET' }) .validator(docsManifestInput) .handler(async ({ data }) => { @@ -232,6 +258,26 @@ export const fetchDocsManifest = createServerFn({ method: 'GET' }) }) }) +export const fetchDocsPathManifest = createServerFn({ method: 'GET' }) + .validator(docsManifestInput) + .handler(async ({ data }) => { + const { repo, branch, docsRoot } = data + + if (shouldUseLocalDocsFiles()) { + return buildDocsPathManifest({ repo, branch, docsRoot }) + } + + return getCachedDocsArtifact({ + repo, + gitRef: branch, + docsRoot, + artifactType: 'docs-path-manifest', + artifactKey: 'default', + isValue: isDocsManifest, + build: () => buildDocsPathManifest({ repo, branch, docsRoot }), + }) + }) + export const fetchDocsRedirect = createServerFn({ method: 'GET' }) .validator(docsRedirectInput) .handler(async ({ data }) => { diff --git a/src/utils/docs.ts b/src/utils/docs.ts index 2e86e8303..45f82cbd7 100644 --- a/src/utils/docs.ts +++ b/src/utils/docs.ts @@ -1,11 +1,18 @@ -import { notFound } from '@tanstack/react-router' +import { isNotFound, notFound } from '@tanstack/react-router' import { fetchDocs, fetchDocsManifest, + fetchDocsPathManifest, fetchDocsRedirect, fetchFile, fetchRepoDirectoryContents, } from './docs.functions' +import { + buildDocsMarkdownRedirectHref, + buildDocsRedirectHref, + resolveDocsPathRedirect, + type DocsPathResolution, +} from './docs-redirects' import { removeLeadingSlash } from './utils' export const loadDocs = async ({ @@ -54,6 +61,196 @@ export async function getDocsManifest(opts: { return fetchDocsManifest({ data: opts }) } +export async function resolveDocsRoutePath(opts: { + branch: string + defaultDocs: string + docsPath: string + docsRoot: string + frameworks: Array + repo: string +}): Promise { + const defaultDocsResolution = getDefaultDocsResolution(opts) + + if (defaultDocsResolution) { + return defaultDocsResolution + } + + const manifest = await fetchDocsPathManifest({ + data: { + repo: opts.repo, + branch: opts.branch, + docsRoot: opts.docsRoot, + }, + }) + + if (manifest.paths.length === 0) { + return { type: 'render', docsPath: opts.docsPath } + } + + return resolveDocsPathRedirect({ + defaultDocs: opts.defaultDocs, + docsPath: opts.docsPath, + frameworks: opts.frameworks, + manifest, + }) +} + +function getDefaultDocsResolution(opts: { + defaultDocs: string + docsPath: string + frameworks: Array +}): DocsPathResolution | null { + const docsPath = normalizeRouteDocsPath(opts.docsPath) + const defaultDocs = normalizeRouteDocsPath(opts.defaultDocs) + + if (!docsPath || !defaultDocs) { + return null + } + + if (docsPath === defaultDocs) { + return { + type: 'render', + docsPath, + } + } + + const [framework, ...restParts] = docsPath.split('/') + const restPath = restParts.join('/') + + if ( + framework && + opts.frameworks.includes(framework) && + restPath === 'overview' && + defaultDocs === `framework/${framework}/overview` + ) { + return { + type: 'redirect', + docsPath: defaultDocs, + } + } + + return null +} + +function normalizeRouteDocsPath(path: string) { + return removeLeadingSlash(path.trim()) + .replace(/\.md$/, '') + .replace(/\/index$/, '') + .replace(/\/+$/g, '') +} + +export type LoadDocsRouteResult = + | { + type: 'loaded' + docsPath: string + doc: Awaited> + } + | { + type: 'redirect' + docsPath: string + } + | { + type: 'not-found' + } + +export async function loadDocsRoute(opts: { + branch: string + defaultDocs: string + docsPath: string + docsRoot: string + frameworks: Array + redirectFromPaths: Array + repo: string +}): Promise { + const resolution = await resolveDocsRoutePathWithRedirects(opts) + + if (resolution.type !== 'render') { + return resolution + } + + try { + return { + type: 'loaded', + docsPath: resolution.docsPath, + doc: await loadDocs({ + repo: opts.repo, + branch: opts.branch, + docsRoot: opts.docsRoot, + docsPath: resolution.docsPath, + }), + } + } catch (error) { + if (!isDocsNotFoundError(error)) { + throw error + } + + const redirectPath = await resolveDocsRedirectFromPaths(opts) + + if (redirectPath !== null) { + return { + type: 'redirect', + docsPath: redirectPath, + } + } + + return { type: 'not-found' } + } +} + +async function resolveDocsRoutePathWithRedirects(opts: { + branch: string + defaultDocs: string + docsPath: string + docsRoot: string + frameworks: Array + redirectFromPaths: Array + repo: string +}): Promise { + const resolution = await resolveDocsRoutePath(opts) + + if (resolution.type !== 'not-found') { + return resolution + } + + const redirectPath = await resolveDocsRedirectFromPaths(opts) + + if (redirectPath !== null) { + return { + type: 'redirect', + docsPath: redirectPath, + } + } + + return resolution +} + +async function resolveDocsRedirectFromPaths(opts: { + branch: string + docsRoot: string + redirectFromPaths: Array + repo: string +}) { + const docsPaths = opts.redirectFromPaths.filter(Boolean) + + if (docsPaths.length === 0) { + return null + } + + return resolveDocsRedirect({ + repo: opts.repo, + branch: opts.branch, + docsRoot: opts.docsRoot, + docsPaths, + }) +} + +function isDocsNotFoundError(error: unknown) { + return ( + isNotFound(error) || + (error && typeof error === 'object' && 'isNotFound' in error) + ) +} + export async function resolveDocsRedirect(opts: { repo: string branch: string @@ -63,4 +260,9 @@ export async function resolveDocsRedirect(opts: { return fetchDocsRedirect({ data: opts }) } -export { fetchFile, fetchRepoDirectoryContents } +export { + buildDocsMarkdownRedirectHref, + buildDocsRedirectHref, + fetchFile, + fetchRepoDirectoryContents, +} diff --git a/src/utils/documents.server.ts b/src/utils/documents.server.ts index 68edda6b1..c305bed5e 100644 --- a/src/utils/documents.server.ts +++ b/src/utils/documents.server.ts @@ -17,6 +17,7 @@ import { import { normalizeRedirectFrom } from './redirects' import { multiSortBy, removeLeadingSlash } from './utils' import { env } from './env' +import { fetchWithTimeout } from './outbound-fetch.server' type FrontMatterValue = | string @@ -115,7 +116,7 @@ async function fetchRemote( let response: Response try { - response = await fetch(href, { + response = await fetchWithTimeout(href, { ...(await getGitHubContentFetchOptionsAsync({ includeApiVersion: false, userAgent: `docs:${owner}/${repo}`, @@ -124,7 +125,7 @@ async function fetchRemote( if (isGitHubAuthFailureStatus(response.status)) { await cancelUnusedResponseBody(response) - response = await fetch(href, { + response = await fetchWithTimeout(href, { ...(await getGitHubContentFetchOptionsAsync({ includeApiVersion: false, includeAuthorization: false, @@ -898,11 +899,14 @@ async function fetchGitHubApiJson(url: string) { let response: Response try { - response = await fetch(url, await getGitHubContentFetchOptionsAsync()) + response = await fetchWithTimeout( + url, + await getGitHubContentFetchOptionsAsync(), + ) if (isGitHubAuthFailureStatus(response.status)) { await cancelUnusedResponseBody(response) - response = await fetch( + response = await fetchWithTimeout( url, await getGitHubContentFetchOptionsAsync({ includeAuthorization: false, diff --git a/tests/docs-redirects.test.ts b/tests/docs-redirects.test.ts new file mode 100644 index 000000000..e36b14055 --- /dev/null +++ b/tests/docs-redirects.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict' +import { + buildDocsMarkdownRedirectHref, + buildDocsRedirectHref, + resolveDocsPathRedirect, + type DocsRedirectManifest, +} from '../src/utils/docs-redirects' +import { publicLibraries } from '../src/libraries' + +const expectedLegacyOverviewTargets: Record = { + ai: 'getting-started/overview', + cli: null, + config: null, + db: 'framework/react/overview', + devtools: 'overview', + form: 'overview', + hotkeys: 'overview', + intent: null, + pacer: 'overview', + query: 'framework/react/overview', + ranger: 'overview', + router: 'overview', + start: 'framework/react/overview', + store: 'overview', + table: 'overview', + virtual: 'introduction', +} + +function manifestWithPaths(paths: Array): DocsRedirectManifest { + return { + paths, + redirects: {}, + } +} + +function assertRedirectsTo(opts: { + defaultDocs: string + docsPath: string + expectedTarget: string + frameworks: Array + manifest: DocsRedirectManifest +}) { + assert.deepEqual( + resolveDocsPathRedirect({ + defaultDocs: opts.defaultDocs, + docsPath: opts.docsPath, + frameworks: opts.frameworks, + manifest: opts.manifest, + }), + { type: 'redirect', docsPath: opts.expectedTarget }, + ) +} + +function assertNotFound(opts: { + defaultDocs: string + docsPath: string + frameworks: Array + manifest: DocsRedirectManifest +}) { + assert.deepEqual( + resolveDocsPathRedirect({ + defaultDocs: opts.defaultDocs, + docsPath: opts.docsPath, + frameworks: opts.frameworks, + manifest: opts.manifest, + }), + { type: 'not-found' }, + ) +} + +for (const library of publicLibraries) { + assert.ok( + Object.hasOwn(expectedLegacyOverviewTargets, library.id), + `${library.id} must have a docs/react/overview redirect expectation`, + ) + + const expectedTarget = expectedLegacyOverviewTargets[library.id] + const defaultDocs = library.defaultDocs ?? 'overview' + const paths = expectedTarget ? [expectedTarget] : [defaultDocs] + const manifest = manifestWithPaths(paths) + + if (expectedTarget) { + assertRedirectsTo({ + defaultDocs, + docsPath: 'react/overview', + expectedTarget, + frameworks: library.frameworks, + manifest, + }) + } else { + assertNotFound({ + defaultDocs, + docsPath: 'react/overview', + frameworks: library.frameworks, + manifest, + }) + } + + assert.deepEqual( + resolveDocsPathRedirect({ + defaultDocs, + docsPath: defaultDocs, + frameworks: library.frameworks, + manifest: manifestWithPaths([defaultDocs]), + }), + { type: 'render', docsPath: defaultDocs }, + `${library.id} canonical default docs render without redirect`, + ) +} + +assertRedirectsTo({ + defaultDocs: 'overview', + docsPath: 'framework/react/overview', + expectedTarget: 'overview', + frameworks: ['react', 'solid'], + manifest: manifestWithPaths(['overview']), +}) + +assertRedirectsTo({ + defaultDocs: 'overview', + docsPath: 'framework/react/guide/search-params', + expectedTarget: 'guide/search-params', + frameworks: ['react', 'solid'], + manifest: manifestWithPaths(['guide/search-params']), +}) + +assertRedirectsTo({ + defaultDocs: 'overview', + docsPath: 'react/guide/search-params', + expectedTarget: 'framework/react/guide/search-params', + frameworks: ['react', 'solid'], + manifest: manifestWithPaths([ + 'framework/react/guide/search-params', + 'guide/search-params', + ]), +}) + +assertNotFound({ + defaultDocs: 'overview', + docsPath: 'ember/overview', + frameworks: ['react', 'solid'], + manifest: manifestWithPaths(['overview', 'framework/react/overview']), +}) + +assertNotFound({ + defaultDocs: 'overview', + docsPath: 'framework/ember/overview', + frameworks: ['react', 'solid'], + manifest: manifestWithPaths(['overview', 'framework/react/overview']), +}) + +assertNotFound({ + defaultDocs: 'overview', + docsPath: 'react/guide/does-not-exist', + frameworks: ['react', 'solid'], + manifest: manifestWithPaths(['overview', 'framework/react/overview']), +}) + +assertRedirectsTo({ + defaultDocs: 'overview', + docsPath: 'react/overview', + expectedTarget: 'overview', + frameworks: [], + manifest: { + paths: ['overview'], + redirects: { + 'react/overview': 'overview', + }, + }, +}) + +assertNotFound({ + defaultDocs: 'overview', + docsPath: 'react/overview', + frameworks: [], + manifest: manifestWithPaths(['overview']), +}) + +assert.equal( + buildDocsRedirectHref({ + baseHref: '/query/v5/docs/react/overview?pm=pnpm#motivation', + docsPath: 'framework/react/overview', + libraryId: 'query', + version: 'v5', + }), + '/query/v5/docs/framework/react/overview?pm=pnpm#motivation', +) + +assert.equal( + buildDocsMarkdownRedirectHref({ + requestUrl: + 'https://tanstack.com/query/v5/docs/react/overview.md?pm=pnpm#motivation', + docsPath: 'framework/react/overview', + libraryId: 'query', + version: 'v5', + }), + 'https://tanstack.com/query/v5/docs/framework/react/overview.md?pm=pnpm#motivation', +) + +console.log('docs redirect tests passed') diff --git a/tests/docs-route-smoke.test.ts b/tests/docs-route-smoke.test.ts new file mode 100644 index 000000000..03807ab02 --- /dev/null +++ b/tests/docs-route-smoke.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { publicLibraries } from '../src/libraries' + +type ExpectedOutcome = 'ok' | 'not-found' + +type SmokeCase = { + expected: ExpectedOutcome + path: string +} + +const baseUrl = process.env.TANSTACK_DOCS_SMOKE_BASE_URL + +test( + 'docs legacy and canonical routes complete for every public library', + { + skip: baseUrl + ? false + : 'Set TANSTACK_DOCS_SMOKE_BASE_URL to run docs route smoke tests', + }, + async () => { + assert.ok(baseUrl) + + const cases = publicLibraries.flatMap((library): Array => { + const defaultDocs = library.defaultDocs ?? 'overview' + const legacyExpected = library.frameworks.includes('react') + ? 'ok' + : 'not-found' + const paths: Array = [ + { + path: `/${library.id}/latest/docs/react/overview`, + expected: legacyExpected, + }, + { + path: `/${library.id}/${library.latestVersion}/docs/react/overview`, + expected: legacyExpected, + }, + { + path: `/${library.id}/latest/docs/${defaultDocs}`, + expected: 'ok', + }, + { + path: `/${library.id}/${library.latestVersion}/docs/${defaultDocs}`, + expected: 'ok', + }, + ] + + if (library.frameworks.includes('react')) { + paths.push({ + path: `/${library.id}/latest/docs/framework/react/overview`, + expected: 'ok', + }) + paths.push({ + path: `/${library.id}/${library.latestVersion}/docs/framework/react/overview`, + expected: 'ok', + }) + } + + return paths + }) + + for (const smokeCase of cases) { + const result = await fetchWithRedirects(new URL(smokeCase.path, baseUrl)) + + assert.notEqual(result.status, 'timeout', `${smokeCase.path} timed out`) + assert.notEqual(result.status, 'error', `${smokeCase.path} failed`) + assert.notEqual(result.status, 500, `${smokeCase.path} returned 500`) + + if (smokeCase.expected === 'ok') { + assert.equal(result.status, 200, `${smokeCase.path} should resolve`) + } else { + assert.equal(result.status, 404, `${smokeCase.path} should 404`) + } + } + }, +) + +async function fetchWithRedirects(startUrl: URL) { + let currentUrl = startUrl + + for (let redirectCount = 0; redirectCount < 6; redirectCount++) { + try { + const response = await fetch(currentUrl, { + redirect: 'manual', + signal: AbortSignal.timeout(5_000), + }) + await response.body?.cancel() + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location') + assert.ok(location, `${currentUrl.href} redirected without location`) + currentUrl = new URL(location, currentUrl) + continue + } + + return { + status: response.status, + url: currentUrl.href, + } + } catch (error) { + if (error instanceof DOMException && error.name === 'TimeoutError') { + return { + status: 'timeout', + url: currentUrl.href, + } + } + + return { + status: 'error', + url: currentUrl.href, + } + } + } + + return { + status: 'error', + url: currentUrl.href, + } +}