Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Fixes reroute behavior for PR previews #345

Merged
merged 4 commits into from
Jan 13, 2025
Merged
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
4 changes: 2 additions & 2 deletions public/gh-reroute/index.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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());
16 changes: 9 additions & 7 deletions public/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import React from "react";
import { createRoot } from "react-dom/client";
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
Expand All @@ -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.
Expand Down
58 changes: 51 additions & 7 deletions website/utils/gh_route_utils.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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("/");
Expand All @@ -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.
Expand All @@ -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;
}
Expand All @@ -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;
}
131 changes: 107 additions & 24 deletions website/utils/test/gh_route_utils.test.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -1,95 +1,178 @@
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/?/");
});
});

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);
}
});
});
Expand Down
Loading