diff --git a/docs/setup/setting-up-versioning.md b/docs/setup/setting-up-versioning.md index 8ebd42783c6..cc90634fa47 100644 --- a/docs/setup/setting-up-versioning.md +++ b/docs/setup/setting-up-versioning.md @@ -64,7 +64,6 @@ MkDocs implements this behavior by default, but there are a few caveats: - the [`site_url`][mkdocs.site_url] must be set correctly in `mkdocs.yml`. See the ["Publishing a new version"](#publishing-a-new-version) section for an example. -- you must be viewing the site at that URL (and not locally, for example). - the redirect happens via JavaScript and there is no way to know which page you will be redirected to ahead of time. diff --git a/src/templates/assets/javascripts/integrations/version/findurl/index.ts b/src/templates/assets/javascripts/integrations/version/findurl/index.ts new file mode 100644 index 00000000000..717ed1ec483 --- /dev/null +++ b/src/templates/assets/javascripts/integrations/version/findurl/index.ts @@ -0,0 +1,135 @@ +import { Sitemap } from "../../sitemap" + +/** See docstring for `selectedVersionCorrespondingURL` for the meaning of these fields. */ +type CorrespondingURLParams = { + selectedVersionSitemap: Sitemap + selectedVersionBaseURL: URL + currentLocation: URL + currentBaseURL: string +} + +/** + * Choose a URL to navigate to when the user chooses a version in the version + * selector. + * + * The parameters in `params` are named as follows, in order to make it clearer + * which parameter means what when invoking the function: + * + * - selectedVersionSitemap: Sitemap - as obtained by fetchSitemap from `${selectedVersionBaseURL}/sitemap.xml` + * + * - selectedVersionBaseURL: URL - usually `${currentBaseURL}/../selectedVersion` + * + * - currentLocation: URL - current web browser location + * + * - currentBaseURL: string - as obtained from `config.base` + * + * @param params - arguments with the meanings explained above. + * @returns the URL to navigate to or null if we can't be sure that the + * corresponding page to the current page exists in the selected version + */ +export function selectedVersionCorrespondingURL( + params: CorrespondingURLParams +): URL | undefined { + const {selectedVersionSitemap, + selectedVersionBaseURL, + currentLocation, + currentBaseURL} = params + const current_path = safeURLParse(currentBaseURL)?.pathname + if (current_path === undefined) { + return + } + const currentRelativePath = stripPrefix(currentLocation.pathname, current_path) + if (currentRelativePath === undefined) { + return + } + const sitemapCommonPrefix = shortestCommonPrefix(selectedVersionSitemap.keys()) + if (!selectedVersionSitemap.has(sitemapCommonPrefix)) { + // We could also check that `commonSitemapPrefix` ends in the canonical version, + // similarly to https://github.com/squidfunk/mkdocs-material/pull/7227. However, + // I don't believe that Mike/MkDocs ever generate sitemaps where it would matter + return + } + + const potentialSitemapURL = safeURLParse(currentRelativePath, sitemapCommonPrefix) + if (!potentialSitemapURL || !selectedVersionSitemap.has(potentialSitemapURL.href)) { + return + } + + const result = safeURLParse(currentRelativePath, selectedVersionBaseURL) + if (!result) { + return + } + result.hash = currentLocation.hash + result.search = currentLocation.search + return result +} + +/** + * A version of `new URL` that never throws. A polyfill for URL.parse() which is + * not yet ubuquitous. + * + * @param url - passed to `new URL` constructor + * @param base - passed to `new URL` constructor + * + * @returns `new URL(url, base)` or undefined if the URL is invalid. + */ +function safeURLParse(url: string|URL, base?: string|URL): URL | undefined { + try { + return new URL(url, base) + } catch { + return + } +} + +// Basic string manipulation + +/** Strip a given prefix from a function + * + * @param s - string + * @param prefix - prefix to strip + * + * @returns either the string with the prefix stripped or undefined if the + * string did not begin with the prefix. + */ +export function stripPrefix(s: string, prefix: string): string | undefined { + if (s.startsWith(prefix)) { + return s.slice(prefix.length) + } + return undefined +} + +/** Find the length of the longest common prefix of two strings + * + * @param s1 - first string + * @param s2 - second string + * + * @returns - the length of the longest common prefix of the two strings. + */ +function commonPrefixLen(s1: string, s2: string): number { + const max = Math.min(s1.length, s2.length) + let result + for (result = 0; result < max; ++result) { + if (s1[result] !== s2[result]) { + break + } + } + return result +} + +/** Find the longest common prefix of any number of strings + * + * @param strs - an iterable of strings + * + * @returns the longest common prefix of all the strings + */ +export function shortestCommonPrefix(strs: Iterable): string { + let result // Undefined if no iterations happened + for (const s of strs) { + if (result === undefined) { + result = s + } else { + result = result.slice(0, commonPrefixLen(result, s)) + } + } + return result ?? "" +} diff --git a/src/templates/assets/javascripts/integrations/version/index.ts b/src/templates/assets/javascripts/integrations/version/index.ts index 50de29ad461..d6c68d278da 100644 --- a/src/templates/assets/javascripts/integrations/version/index.ts +++ b/src/templates/assets/javascripts/integrations/version/index.ts @@ -48,6 +48,8 @@ import { import { fetchSitemap } from "../sitemap" +import { selectedVersionCorrespondingURL } from "./findurl" + /* ---------------------------------------------------------------------------- * Helper types * ------------------------------------------------------------------------- */ @@ -122,22 +124,23 @@ export function setupVersionSelector( return EMPTY } ev.preventDefault() - return of(url) + return of(new URL(url)) } } return EMPTY }), - switchMap(url => { - return fetchSitemap(new URL(url)) - .pipe( - map(sitemap => { - const location = getLocation() - const path = location.href.replace(config.base, url) - return sitemap.has(path.split("#")[0]) - ? new URL(path) - : new URL(url) - }) - ) + switchMap(selectedVersionBaseURL => { + return fetchSitemap(selectedVersionBaseURL).pipe( + map( + sitemap => + selectedVersionCorrespondingURL({ + selectedVersionSitemap: sitemap, + selectedVersionBaseURL, + currentLocation: getLocation(), + currentBaseURL: config.base + }) ?? selectedVersionBaseURL, + ), + ) }) ) )