diff --git a/docusaurus.config.ts b/docusaurus.config.ts index e3c379db9b..cf49e94440 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -854,6 +854,48 @@ function retiredPatchTreeRedirects(routePath: string): string[] { return redirects; } +// The paths that should redirect to `routePath` when it is a page at the site +// root, for URLs the path-for-path pass cannot cover because the page has no +// twin in a frozen tree: +// - /stable/... and //... (for example /8.6.1/...). The root +// docs carry no version prefix, but old links use /stable/ and readers try +// the version number they see in the navbar. +// - Hosted pages under every published patch of a frozen minor +// (/7.6.5/hosted/..., /6.28.2/hosted/...). Hosted products are not +// versioned with the SDK: 6.28 has no hosted tree and 7.6.15 lacks the +// newer pages, so these go to the current page. A page that does have a +// 7.6.15 twin keeps the path-for-path redirect, which is written first. +// Empty for anything under a version prefix. +function rootRouteRedirects(routePath: string): string[] { + // Route paths carry the base URL, and a preview build's is not the root. + if (process.env.base_url && process.env.base_url !== "/") return []; + if (/^\/(?:next|\d+\.\d+\.\d+)(?:\/|$)/.test(routePath)) return []; + if (routePath.endsWith(".html")) return []; + const rootVersion = + effectiveLastVersion === "current" + ? docsVersions.current?.label + : effectiveLastVersion; + const redirects = [`/stable${routePath}`]; + if (rootVersion) redirects.push(`/${rootVersion}${routePath}`); + if (routePath.startsWith("/hosted/")) { + for (const [minor, firstPatch] of Object.entries( + FIRST_PUBLISHED_PATCH_BY_MINOR, + )) { + // The newest frozen patch of the minor, should a transition ever list + // two. No frozen patch: -Infinity, and no redirects. + const livePatch = Math.max( + ...frozenVersionNames() + .filter((v) => v.startsWith(`${minor}.`)) + .map((v) => Number(v.slice(minor.length + 1))), + ); + for (let p = firstPatch; p <= livePatch; p++) { + redirects.push(`/${minor}.${p}${routePath}`); + } + } + } + return redirects; +} + // Writes the retired-tree redirects as static pages, in the same format as // @docusaurus/plugin-client-redirects. They are not handed to that plugin // because it writes every file at once: ~10,000 parallel writes made it fail @@ -878,17 +920,21 @@ function retiredPatchTreeRedirectsPlugin() { ); const written = new Set(); - for (const routePath of routesPaths) { - const target = routePath.endsWith("/") ? routePath : `${routePath}/`; - for (const from of retiredPatchTreeRedirects(routePath)) { - const dir = join(outDir, from); - const file = join(dir, "index.html"); - // A route can be listed with and without its trailing slash, and a - // real page always wins over a redirect. - if (written.has(file) || (await exists(file))) continue; - await mkdir(dir, { recursive: true }); - await writeFile(file, redirectPageHtml(target), "utf8"); - written.add(file); + // Path-for-path first, so a retired page with a twin in its own frozen + // tree goes there rather than to the root fallback of the second pass. + for (const redirectsFor of [retiredPatchTreeRedirects, rootRouteRedirects]) { + for (const routePath of routesPaths) { + const target = routePath.endsWith("/") ? routePath : `${routePath}/`; + for (const from of redirectsFor(routePath)) { + const dir = join(outDir, from); + const file = join(dir, "index.html"); + // A route can be listed with and without its trailing slash, and a + // real page always wins over a redirect. + if (written.has(file) || (await exists(file))) continue; + await mkdir(dir, { recursive: true }); + await writeFile(file, redirectPageHtml(target), "utf8"); + written.add(file); + } } } }, @@ -1259,7 +1305,9 @@ const config: Config = { // the versions this build contains and a preview deploy can exclude itself. robotsTxtPlugin, // Path-for-path redirects from retired patch trees (/7.6.14/...) to the - // live patch of the same minor. See retiredPatchTreeRedirects. + // live patch of the same minor, then /stable/, // and retired + // hosted URLs to the root. See retiredPatchTreeRedirects and + // rootRouteRedirects. retiredPatchTreeRedirectsPlugin, ], diff --git a/src/theme/NotFound/Content/index.js b/src/theme/NotFound/Content/index.js index 3faa01631d..4592b29eaa 100644 --- a/src/theme/NotFound/Content/index.js +++ b/src/theme/NotFound/Content/index.js @@ -1,6 +1,9 @@ import React, { useEffect } from "react"; import { useHistory, useLocation } from "@docusaurus/router"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment"; +import { useVersions } from "@docusaurus/plugin-content-docs/client"; +import routes from "@generated/routes"; import OriginalNotFoundContent from "@theme-original/NotFound/Content"; import { CURRENT_DOCS_PATH, @@ -33,6 +36,80 @@ export function getApiReferenceFallbackUrl(pathname, prefixByMajor) { return target === pathname ? null : target; } +// A path as the router matches it: no anchor, no trailing .html or +// /index.html (the router strips both), and one trailing slash. +function routeKey(path) { + const bare = path.split("#")[0].replace(/(?:\/index)?\.html$/, ""); + return bare.endsWith("/") ? bare : `${bare}/`; +} + +let appRouteKeys = null; + +// Whether the app renders a page at `pathname`. Static redirect pages are not +// routes: client-side navigation never loads them. +export function isAppRoute(pathname) { + if (!appRouteKeys) { + appRouteKeys = new Set(); + const collect = (list) => + list.forEach((route) => { + if (route.routes) collect(route.routes); + else if (route.exact && route.path) appRouteKeys.add(routeKey(route.path)); + }); + collect(routes); + } + return appRouteKeys.has(routeKey(pathname)); +} + +// A version prefix this site does not serve: /6.18/..., /7.5/..., /6.15.11/..., +// /5.0/sdks/.... Readers type or edit these, and old links carry them. On a +// full page load a retired patch of a served minor (/7.6.5/...) gets a static +// redirect page instead, but client-side navigation, and a retired page with no +// live twin, still end up here. This is the catch-all: the same path under the +// tree that serves the reader's major, or under the root for a major the site +// no longer serves. getFallbackUrl then catches a page that tree does not have. +// Paths with a file extension are legacy files, not docs routes, and are left +// alone. +const UNSERVED_VERSION_PATTERN = /^\/(\d+)\.\d+(?:\.\d+)?(\/[^.]*)?$/; + +export function getUnservedVersionFallbackUrl( + pathname, + versions, + isRoute = isAppRoute, +) { + const match = pathname.match(UNSERVED_VERSION_PATTERN); + if (!match || !versions) return null; + const [, major, captured = ""] = match; + const rest = captured || "/"; + // The API reference has its own prefixes (getApiReferenceFallbackUrl). A + // docs tree would send the reader straight back there, and round again. + if (rest === "/data-capture-sdk" || rest.startsWith("/data-capture-sdk/")) { + return null; + } + const prefixes = versions.map((v) => v.path.replace(/\/$/, "")); + const ownPrefix = pathname.slice(0, pathname.length - captured.length); + // A frozen tree of the major wins over current, as in + // buildApiReferencePrefixByMajor: during a beta the served 8.x tree is a + // better target than the unreleased one. + const sameMajor = versions.filter( + (v) => (v.name === "current" ? v.label : v.name).split(".")[0] === major, + ); + const tree = sameMajor.find((v) => v.name !== "current") ?? sameMajor[0]; + const prefix = tree ? tree.path.replace(/\/$/, "") : ""; + // Hosted products are not versioned with the SDK, and a frozen tree has + // only some of their pages: the tree's own page if it has one, else the + // current one. Also for a served prefix, whose missing hosted pages would + // otherwise get no fallback at all. + if (rest.startsWith("/hosted/")) { + const target = [`${prefix}${rest}`, rest].find( + (candidate) => candidate !== pathname && isRoute(candidate), + ); + return target ?? null; + } + if (prefixes.includes(ownPrefix)) return null; + const target = `${prefix}${rest}`; + return target === pathname ? null : target; +} + function getFallbackUrl(pathname) { // Matches optional version prefix + /sdks/ + framework (including compound like net/ios, xamarin/ios) const match = pathname.match( @@ -68,11 +145,21 @@ export default function NotFoundContent(props) { const location = useLocation(); const history = useHistory(); const { siteConfig } = useDocusaurusContext(); + // The URL as requested. The router's location drops a trailing ".html" + // (/7.6.6/.../vin.html arrives as /7.6.6/.../vin), but the API reference's + // pages only exist with it, and it is what tells a legacy file from a route. + const requestedPath = ExecutionEnvironment.canUseDOM + ? window.location.pathname + : location.pathname; const apiReferenceUrl = getApiReferenceFallbackUrl( - location.pathname, + requestedPath, siteConfig.customFields?.apiReferencePrefixByMajor, ); - const fallbackUrl = apiReferenceUrl ? null : getFallbackUrl(location.pathname); + const versions = useVersions(undefined); + const fallbackUrl = apiReferenceUrl + ? null + : (getUnservedVersionFallbackUrl(requestedPath, versions) ?? + getFallbackUrl(location.pathname)); useEffect(() => { if (apiReferenceUrl) { @@ -81,7 +168,7 @@ export default function NotFoundContent(props) { // 404 again. window.location.replace(apiReferenceUrl + location.search + location.hash); } else if (fallbackUrl) { - history.replace(fallbackUrl); + history.replace(fallbackUrl + location.search + location.hash); } }, [apiReferenceUrl, fallbackUrl]);