diff --git a/website/src/components/templates/BaseTemplate.tsx b/website/src/components/templates/BaseTemplate.tsx index 0ca8b22aca..f84e6060ad 100644 --- a/website/src/components/templates/BaseTemplate.tsx +++ b/website/src/components/templates/BaseTemplate.tsx @@ -3,7 +3,7 @@ import type { FC, PropsWithChildren } from "hono/jsx"; import { basePath, originUrl, typstOfficialDocsUrl } from "../../metadata"; import { Translation, translation } from "../../translation/"; import type { Page } from "../../types/model"; -import { joinPath, removeBasePath } from "../../utils/path"; +import { joinPath, shiftBase } from "../../utils/path"; import { getTranslationStatus } from "../../utils/translationStatus"; import { CaretRightCircleIcon, @@ -50,9 +50,10 @@ export const BaseTemplate: FC = ({ joinPath(basePath, "/favicon.png"), originUrl, ).toString(); - const typstOfficialRouteUrl = joinPath( + const typstOfficialRouteUrl = shiftBase( + route, + basePath, typstOfficialDocsUrl, - removeBasePath(basePath, route), ); return ( diff --git a/website/src/components/templates/SymbolsTemplate.tsx b/website/src/components/templates/SymbolsTemplate.tsx index 38afa04da6..719db854ce 100644 --- a/website/src/components/templates/SymbolsTemplate.tsx +++ b/website/src/components/templates/SymbolsTemplate.tsx @@ -1,5 +1,7 @@ import type { FC } from "hono/jsx"; +import { basePath, typstOfficialDocsUrl } from "../../metadata"; import type { Page, SymbolsBody } from "../../types/model"; +import { shiftBase } from "../../utils/path"; import type { BaseTemplateProps } from "./BaseTemplate"; export type SymbolsTemplateProps = Omit & { @@ -9,7 +11,7 @@ export type SymbolsTemplateProps = Omit & { }; export const SymbolsTemplate: FC = ({ page }) => { - const redirectUrl = `https://typst.app${page.route}`; + const redirectUrl = shiftBase(page.route, basePath, typstOfficialDocsUrl); return ( diff --git a/website/src/utils/path.test.ts b/website/src/utils/path.test.ts index 2956233c74..c60a09800e 100644 --- a/website/src/utils/path.test.ts +++ b/website/src/utils/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { joinPath, removeBasePath } from "./path"; +import { joinPath, removeBasePath, shiftBase } from "./path"; describe("joinPath", () => { it("should join base and path with single slash", () => { @@ -119,3 +119,154 @@ describe("removeBasePath", () => { expect(removeBasePath("", "/foo/bar")).toBe("/foo/bar"); }); }); + +describe("shiftBase", () => { + describe("normal usages", () => { + it("should handle non-root oldBasePath and full newBaseUrl", () => { + for (const oldBasePath of ["/base", "/base/"]) { + for (const newBaseUrl of [ + "https://typst.app/docs", + "https://typst.app/docs/", + ]) { + expect(shiftBase("/base/foo/bar/", oldBasePath, newBaseUrl)).toBe( + "https://typst.app/docs/foo/bar/", + ); + expect(shiftBase("/base/", oldBasePath, newBaseUrl)).toBe( + "https://typst.app/docs/", + ); + } + } + }); + + it("should handle root oldBasePath and full newBaseUrl", () => { + for (const oldBasePath of ["/", ""]) { + for (const newBaseUrl of [ + "https://typst.app/docs", + "https://typst.app/docs/", + ]) { + expect(shiftBase("/foo/bar/", oldBasePath, newBaseUrl)).toBe( + "https://typst.app/docs/foo/bar/", + ); + expect(shiftBase("/", oldBasePath, newBaseUrl)).toBe( + "https://typst.app/docs/", + ); + } + } + }); + + it("should handle non-root oldBasePath and newBaseUrl without origin", () => { + for (const oldBasePath of ["/ja-JP/docs", "/ja-JP/docs/"]) { + for (const newBaseUrl of ["/en-US/docs", "/en-US/docs/"]) { + expect( + shiftBase("/ja-JP/docs/foo/bar/", oldBasePath, newBaseUrl), + ).toBe("/en-US/docs/foo/bar/"); + expect(shiftBase("/ja-JP/docs/", oldBasePath, newBaseUrl)).toBe( + "/en-US/docs/", + ); + } + } + }); + }); + + // The following tests are generated by AI automatically. They describe the behaviors in edge cases. + // However, these behaviors are not actually used, and their result and may not meet actual needs. + // Therefore, the following usages should be avoided in practice. + describe("generated usages", () => { + it("should handle edge cases with empty oldBasePath and newBaseUrl", () => { + expect(shiftBase("/foo/bar", "", "")).toBe("/foo/bar"); + expect(shiftBase("/", "", "")).toBe("/"); + expect(shiftBase("", "", "")).toBe(""); + }); + + it("should handle edge cases with empty route", () => { + expect(shiftBase("", "/base", "/new")).toBe("/new/"); + expect(shiftBase("", "/", "/new")).toBe("/new/"); + expect(shiftBase("", "", "/new")).toBe("/new/"); + }); + + it("should handle edge cases with only slashes", () => { + expect(shiftBase("/", "/", "/new")).toBe("/new/"); + expect(shiftBase("/", "/base", "/new")).toBe("/new/"); + expect(shiftBase("/base", "/base", "/new")).toBe("/new/"); + }); + + it("should handle routes with duplicate slashes", () => { + expect(shiftBase("/base//foo//bar", "/base", "/new")).toBe( + "/new/foo/bar", + ); + expect(shiftBase("//base//foo//", "/base", "/new")).toBe( + "/new/base/foo/", + ); + expect(shiftBase("//base//", "/base", "/new")).toBe("/new/base/"); + }); + + it("should handle routes with special characters", () => { + expect(shiftBase("/base/@/foo", "/base", "/new")).toBe("/new/@/foo"); + expect(shiftBase("/base/#/foo", "/base", "/new")).toBe("/new/#/foo"); + expect(shiftBase("/base/!$/foo", "/base", "/new")).toBe("/new/!$/foo"); + }); + + it("should handle routes with trailing slashes in newBaseUrl", () => { + expect(shiftBase("/base/foo", "/base", "/new/")).toBe("/new/foo"); + expect(shiftBase("/base/foo/", "/base", "/new/")).toBe("/new/foo/"); + expect(shiftBase("/base/", "/base", "/new/")).toBe("/new/"); + }); + + it("should handle routes with trailing slashes in oldBasePath", () => { + expect(shiftBase("/base/foo", "/base/", "/new")).toBe("/new/foo"); + expect(shiftBase("/base/foo/", "/base/", "/new")).toBe("/new/foo/"); + expect(shiftBase("/base/", "/base/", "/new")).toBe("/new/"); + }); + + it("should handle routes with mixed slashes and empty parts", () => { + expect(shiftBase("/base//foo", "/base", "/new")).toBe("/new/foo"); + expect(shiftBase("/base/foo//", "/base", "/new")).toBe("/new/foo/"); + expect(shiftBase("/base//", "/base", "/new")).toBe("/new/"); + }); + + it("should handle newBaseUrl with https://", () => { + expect(shiftBase("/base/foo", "/base", "https://example.com/new")).toBe( + "https://example.com/new/foo", + ); + expect(shiftBase("/base/foo/", "/base", "https://example.com/new")).toBe( + "https://example.com/new/foo/", + ); + expect(shiftBase("/base/", "/base", "https://example.com/new")).toBe( + "https://example.com/new/", + ); + expect(shiftBase("/base", "/base", "https://example.com/new")).toBe( + "https://example.com/new/", + ); + }); + + it("should handle newBaseUrl with //example.com", () => { + expect(shiftBase("/base/foo", "/base", "//example.com/new")).toBe( + "//example.com/new/foo", + ); + expect(shiftBase("/base/foo/", "/base", "//example.com/new")).toBe( + "//example.com/new/foo/", + ); + expect(shiftBase("/base/", "/base", "//example.com/new")).toBe( + "//example.com/new/", + ); + expect(shiftBase("/base", "/base", "//example.com/new")).toBe( + "//example.com/new/", + ); + }); + + it("should handle newBaseUrl with only origin and no path", () => { + expect(shiftBase("/base/foo", "/base", "https://example.com")).toBe( + "https://example.com/foo", + ); + expect(shiftBase("/base/foo/", "/base", "https://example.com")).toBe( + "https://example.com/foo/", + ); + expect(shiftBase("/base/", "/base", "https://example.com")).toBe( + "https://example.com/", + ); + expect(shiftBase("/base", "/base", "https://example.com")).toBe( + "https://example.com/", + ); + }); + }); +}); diff --git a/website/src/utils/path.ts b/website/src/utils/path.ts index 0f3b003635..b92711b168 100644 --- a/website/src/utils/path.ts +++ b/website/src/utils/path.ts @@ -67,3 +67,22 @@ export const removeBasePath = (basePath: string, route: string): string => { const offset = basePath.length - (basePath.endsWith("/") ? 1 : 0); return route.slice(offset); }; + +/** + * Replace the oldBasePath in a page route with a newBaseUrl. + * + * @param route - The route string to process. + * @param oldBasePath - The old base path to be removed from the route. + * @param newBaseUrl - The new base URL (may include origin) to be prepended to the route. + * @returns The route string with its base replaced. + * + * @example + * ```ts + * shiftBase("/base/foo/bar/", "/base/", "https://typst.app/docs/") // -> "https://typst.app/docs/foo/bar/" + * ``` + */ +export const shiftBase = ( + route: string, + oldBasePath: string, + newBaseUrl: string, +): string => joinPath(newBaseUrl, removeBasePath(oldBasePath, route));