From 5e4051ce3921242be0a3cf4a4252739d8c065ef1 Mon Sep 17 00:00:00 2001 From: Peyton Lee Date: Tue, 17 Dec 2024 14:17:57 -0800 Subject: [PATCH 1/2] fix: Fixes reroute behavior for PR previews --- public/gh-reroute/index.tsx | 4 +-- public/index.tsx | 16 +++++---- website/utils/gh_route_utils.ts | 58 +++++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/public/gh-reroute/index.tsx b/public/gh-reroute/index.tsx index 578a5a44..aa468d02 100644 --- a/public/gh-reroute/index.tsx +++ b/public/gh-reroute/index.tsx @@ -1,4 +1,4 @@ -import { convertUrlToQueryStringPath } from "../../website/utils/gh_route_utils"; +import { encodeGitHubPagesUrl } from "../../website/utils/gh_route_utils"; // Hide the default 404 page content and just show a blank screen. // The content should only be shown if the browser doesn't support JavaScript. @@ -10,6 +10,6 @@ window.onload = () => { // Convert the current URL to a query string path and redirect the browser. const location = window.location; const locationUrl = new URL(location.toString()); -const newUrl = convertUrlToQueryStringPath(locationUrl, 1); +const newUrl = encodeGitHubPagesUrl(locationUrl); location.replace(newUrl); console.log("Redirecting to " + newUrl.toString()); diff --git a/public/index.tsx b/public/index.tsx index a7a5c488..8a5a5fde 100644 --- a/public/index.tsx +++ b/public/index.tsx @@ -2,12 +2,14 @@ import React from "react"; import ReactDOM from "react-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { decodeGitHubPagesUrl, isEncodedPathUrl, tryRemoveHashRouting } from "../website/utils/gh_route_utils"; + +import StyleProvider from "../src/aics-image-viewer/components/StyleProvider"; // Components import AppWrapper from "../website/components/AppWrapper"; -import LandingPage from "../website/components/LandingPage"; import ErrorPage from "../website/components/ErrorPage"; -import { isQueryStringPath, convertQueryStringPathToUrl } from "../website/utils/gh_route_utils"; -import StyleProvider from "../src/aics-image-viewer/components/StyleProvider"; +import LandingPage from "../website/components/LandingPage"; + import "./App.css"; // vars filled at build time using webpack DefinePlugin @@ -18,11 +20,11 @@ console.log(`volume-viewer Version ${VOLUMEVIEWER_VERSION}`); const basename = WEBSITE3DCELLVIEWER_BASENAME; -// Check for redirects in the query string, and update browser history state. +// Decode URL path if it was encoded for GitHub pages or uses hash routing. const locationUrl = new URL(window.location.toString()); -if (isQueryStringPath(locationUrl)) { - const url = convertQueryStringPathToUrl(locationUrl); - const newRelativePath = url.pathname + url.search + url.hash; +if (locationUrl.hash !== "" || isEncodedPathUrl(locationUrl)) { + const decodedUrl = tryRemoveHashRouting(decodeGitHubPagesUrl(locationUrl)); + const newRelativePath = decodedUrl.pathname + decodedUrl.search + decodedUrl.hash; console.log("Redirecting to " + newRelativePath); // Replaces the query string path with the original path now that the // single-page app has loaded. This lets routing work as normal below. diff --git a/website/utils/gh_route_utils.ts b/website/utils/gh_route_utils.ts index 407afc02..343dd16a 100644 --- a/website/utils/gh_route_utils.ts +++ b/website/utils/gh_route_utils.ts @@ -1,7 +1,7 @@ const ESCAPED_AMPERSAND = "~and~"; /** - * Converts the path component of a URL into a query string. Used to redirect the browser + * Encodes the path component of a URL into a query string. Used to redirect the browser * for single-page apps when the server is not configured to serve the app for all paths, * e.g. GitHub pages. * @@ -23,7 +23,7 @@ const ESCAPED_AMPERSAND = "~and~"; * * @returns The URL with the path converted to a query string, and the original query string escaped. */ -export function convertUrlToQueryStringPath(url: URL, basePathSegments: number = 0): URL { +export function encodeUrlPathAsQueryString(url: URL, basePathSegments: number = 0): URL { const pathSegments = url.pathname.split("/"); const basePath = pathSegments.slice(0, basePathSegments + 1).join("/"); const remainingPath = pathSegments.slice(basePathSegments + 1).join("/"); @@ -39,10 +39,6 @@ export function convertUrlToQueryStringPath(url: URL, basePathSegments: number = return new URL(newUrl); } -export function isQueryStringPath(url: URL): boolean { - return url.search !== "" && url.search.startsWith("?/"); -} - /** * Converts a query string back into a complete URL. Used in combination with `convertUrlToQueryStringPath()`. * to redirect the browser for single-page apps when the server cannot be configured, e.g. GitHub pages. @@ -51,7 +47,7 @@ export function isQueryStringPath(url: URL): boolean { * @param url - The URL with a path converted to a query string, and the original query string escaped. * @returns The original URL, with path instead of a query string. */ -export function convertQueryStringPathToUrl(url: URL): URL { +export function decodeUrlQueryStringPath(url: URL): URL { if (!url.search || !url.search.startsWith("?/")) { return url; } @@ -64,3 +60,51 @@ export function convertQueryStringPathToUrl(url: URL): URL { return new URL(`${url.origin}${url.pathname}${newPathAndQueryString}${url.hash}`); } + +export function isEncodedPathUrl(url: URL): boolean { + return url.search !== "" && url.search.startsWith("?/"); +} + +/** + * Encodes a URL for GitHub pages by converting the path to a query string. + * See `encodeUrlPathAsQueryString()` for more details. + */ +export function encodeGitHubPagesUrl(url: URL): URL { + url = new URL(url); // Clone the URL to avoid modifying the original + if (url.hostname === "allen-cell-animated.github.io") { + if (url.pathname.toString().includes("pr-preview")) { + return encodeUrlPathAsQueryString(url, 3); + } + // Redirect `/main` paths back to root `/` + if (url.pathname.toString().includes("main")) { + url.pathname = url.pathname.replace("/main", ""); + } + return encodeUrlPathAsQueryString(url, 1); + } + + return url; +} + +/** + * Decodes a URL that was encoded for GitHub pages, e.g. by `encodeGitHubPagesUrl()`. + * See `decodeUrlQueryStringPath()` for more details. + */ +export function decodeGitHubPagesUrl(url: URL): URL { + return decodeUrlQueryStringPath(url); +} + +/** + * Changes URLs with hash-based routing to path-based routing by removing the hash from + * the URL. Does nothing to URLs that do not use hash-based routing. + */ +export function tryRemoveHashRouting(url: URL): URL { + // Remove #/ from the URL path + if (url.hash.startsWith("#/")) { + const hashContents = url.hash.slice(2); + const [path, queryParams] = hashContents.split("?"); + url.pathname += path; + url.search = queryParams ? `?${queryParams}` : ""; + url.hash = ""; + } + return url; +} From 917f540e076b051d96e6d3471b9b96456017f3d6 Mon Sep 17 00:00:00 2001 From: Peyton Lee Date: Tue, 17 Dec 2024 14:25:11 -0800 Subject: [PATCH 2/2] fix: Unit tests --- website/utils/test/gh_route_utils.test.ts | 131 ++++++++++++++++++---- 1 file changed, 107 insertions(+), 24 deletions(-) diff --git a/website/utils/test/gh_route_utils.test.ts b/website/utils/test/gh_route_utils.test.ts index acdd7b39..636dd127 100644 --- a/website/utils/test/gh_route_utils.test.ts +++ b/website/utils/test/gh_route_utils.test.ts @@ -1,32 +1,39 @@ import { describe, expect, it } from "@jest/globals"; -import { convertQueryStringPathToUrl, convertUrlToQueryStringPath } from "../gh_route_utils"; + +import { + decodeGitHubPagesUrl, + decodeUrlQueryStringPath, + encodeGitHubPagesUrl, + encodeUrlPathAsQueryString, + tryRemoveHashRouting, +} from "../gh_route_utils"; describe("Route utils", () => { describe("convertUrlToQueryStringPath", () => { it("converts paths to query string", () => { const url = new URL("https://www.example.com/one/two"); - const convertedUrl = convertUrlToQueryStringPath(url, 0); + const convertedUrl = encodeUrlPathAsQueryString(url, 0); expect(convertedUrl.toString()).toEqual("https://www.example.com/?/one/two"); }); it("handles extra slashes", () => { const url = new URL("https://www.example.com/one/two/"); - const convertedUrl = convertUrlToQueryStringPath(url, 0); + const convertedUrl = encodeUrlPathAsQueryString(url, 0); expect(convertedUrl.toString()).toEqual("https://www.example.com/?/one/two/"); }); it("handles original query params and hashes", () => { const url = new URL("https://www.example.com/one/two?a=0&b=1#hash"); - const convertedUrl = convertUrlToQueryStringPath(url, 0); + const convertedUrl = encodeUrlPathAsQueryString(url, 0); expect(convertedUrl.toString()).toEqual("https://www.example.com/?/one/two&a=0~and~b=1#hash"); }); it("handles base path segments", () => { const url = new URL("https://www.example.com/one/two/"); - const convertedUrl1 = convertUrlToQueryStringPath(url, 1); + const convertedUrl1 = encodeUrlPathAsQueryString(url, 1); expect(convertedUrl1.toString()).toEqual("https://www.example.com/one/?/two/"); - const convertedUrl2 = convertUrlToQueryStringPath(url, 2); + const convertedUrl2 = encodeUrlPathAsQueryString(url, 2); expect(convertedUrl2.toString()).toEqual("https://www.example.com/one/two/?/"); }); }); @@ -34,62 +41,138 @@ describe("Route utils", () => { describe("convertQueryStringPathToUrl", () => { it("returns original url", () => { const url = new URL("https://www.example.com/one/two/"); - const convertedUrl = convertUrlToQueryStringPath(url, 0); - const restoredUrl = convertQueryStringPathToUrl(convertedUrl); + const convertedUrl = encodeUrlPathAsQueryString(url, 0); + const restoredUrl = decodeUrlQueryStringPath(convertedUrl); expect(restoredUrl.toString()).toEqual(url.toString()); }); it("ignores normal urls", () => { const url = new URL("https://www.example.com/one/two/"); - const restoredUrl = convertQueryStringPathToUrl(url); + const restoredUrl = decodeUrlQueryStringPath(url); expect(restoredUrl.toString()).toEqual(url.toString()); }); it("ignores normal urls with query parameters", () => { const url = new URL("https://www.example.com/one/two/?a=0"); - const restoredUrl = convertQueryStringPathToUrl(url); + const restoredUrl = decodeUrlQueryStringPath(url); expect(restoredUrl.toString()).toEqual(url.toString()); }); it("handles converted query params and hashes", () => { const url = new URL("https://www.example.com/one/two?a=0&b=1#hash"); - const convertedUrl = convertUrlToQueryStringPath(url, 0); - const restoredUrl = convertQueryStringPathToUrl(convertedUrl); + const convertedUrl = encodeUrlPathAsQueryString(url, 0); + const restoredUrl = decodeUrlQueryStringPath(convertedUrl); expect(restoredUrl.toString()).toEqual(url.toString()); }); }); - describe("Convert GitHub pages URLs", () => { - it("handles viewer links", () => { - const urlsToTest: string[][] = [ + describe("URL encoding and decoding", () => { + function testUrlEncodingAndDecoding(urls: string[][]): void { + for (const [input, encoded, decoded] of urls) { + const url = new URL(input); + + const encodedInput = encodeGitHubPagesUrl(url); + expect(encodedInput.toString()).toEqual(encoded); + expect(tryRemoveHashRouting(decodeGitHubPagesUrl(encodedInput)).toString()).toEqual(decoded); + } + } + + it("handles basic viewer links", () => { + testUrlEncodingAndDecoding([ [ "https://allen-cell-animated.github.io/website-3d-cell-viewer/", "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/", ], [ "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer", "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/viewer", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer", + ], + [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer?collection=https://example.com/collection.json", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/viewer&collection=https://example.com/collection.json", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer?collection=https://example.com/collection.json", + ], + [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer?collection=https://example.com/collection.json&dataset=example", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/viewer&collection=https://example.com/collection.json~and~dataset=example", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer?collection=https://example.com/collection.json&dataset=example", + ], + ]); + }); + + it("removes hash routing", () => { + testUrlEncodingAndDecoding([ + [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/#/viewer", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/#/viewer", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer", ], [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/main/#/viewer?url=https://example.com", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/#/viewer?url=https://example.com", "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer?url=https://example.com", - "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/viewer&url=https://example.com", ], + ]); + }); + + it("reroutes from main to root", () => { + testUrlEncodingAndDecoding([ [ - "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer?url=https://example.com,https://example2.com", - "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/viewer&url=https://example.com,https://example2.com", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/main/", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/", ], [ - "https://allen-cell-animated.github.io/website-3d-cell-viewer/viewer?url=https://example.com&file=example.json", - "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/viewer&url=https://example.com~and~file=example.json", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/main", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/?/", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/", + ], + ]); + }); + + it("keeps pr-preview basepaths", () => { + testUrlEncodingAndDecoding([ + [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/?/", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/", + ], + [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/viewer", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/?/viewer", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/viewer", + ], + [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/#/viewer", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/?/#/viewer", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/viewer", + ], + [ + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/#/viewer?collection=https://example.com/collection.json&dataset=example", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/?/#/viewer?collection=https://example.com/collection.json&dataset=example", + "https://allen-cell-animated.github.io/website-3d-cell-viewer/pr-preview/pr-100/viewer?collection=https://example.com/collection.json&dataset=example", + ], + ]); + }); + + it("handles hash removal when decoding dev server links", () => { + const urlsToTest = [ + ["https://example-server.com/website-3d-cell-viewer/", "https://example-server.com/website-3d-cell-viewer/"], + [ + "https://example-server.com/website-3d-cell-viewer/#/viewer", + "https://example-server.com/website-3d-cell-viewer/viewer", + ], + [ + "https://example-server.com/website-3d-cell-viewer/#/viewer?collection=https://example.com/collection.json&dataset=example", + "https://example-server.com/website-3d-cell-viewer/viewer?collection=https://example.com/collection.json&dataset=example", ], ]; for (const [input, expected] of urlsToTest) { const url = new URL(input); - const convertedUrl = convertUrlToQueryStringPath(url, 1); - - expect(convertedUrl.toString()).toEqual(expected); - expect(convertQueryStringPathToUrl(convertedUrl).toString()).toEqual(input); + expect(tryRemoveHashRouting(decodeGitHubPagesUrl(url)).toString()).toEqual(expected); } }); });