Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<root version>/... (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
Expand All @@ -878,17 +920,21 @@ function retiredPatchTreeRedirectsPlugin() {
);

const written = new Set<string>();
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);
}
}
}
},
Expand Down Expand Up @@ -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/, /<root version>/ and retired
// hosted URLs to the root. See retiredPatchTreeRedirects and
// rootRouteRedirects.
retiredPatchTreeRedirectsPlugin,
],

Expand Down
93 changes: 90 additions & 3 deletions src/theme/NotFound/Content/index.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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]);

Expand Down
Loading