Skip to content

Commit c1286ad

Browse files
committed
refactor(navigation): replace URL canonicalization with version path resolution
- Introduce `resolveVersionedPath` for handling versioned routes. - Simplify bootstrap navigation logic to include preferences. - Remove outdated `canonicalizeMalformedVersionURL` method. - Update tests to reflect the new routing approach.
1 parent 924afa7 commit c1286ad

File tree

5 files changed

+152
-112
lines changed

5 files changed

+152
-112
lines changed

src/navigation.svelte.ts

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,22 @@ import {
77
} from "./preferences.svelte";
88
import {
99
buildURL,
10-
canonicalizeMalformedVersionURL,
1110
initializeRouting,
1211
navigateToURL,
1312
page,
1413
replaceURLDebounced,
14+
resolveVersionedPath,
1515
type RouteTarget,
1616
} from "./routing.svelte";
1717
import {
1818
buildsState,
19+
type BuildsState,
1920
initializeBuildsState,
2021
resolveBuildVersion,
2122
} from "./builds.svelte";
2223
import { initializeUILocale } from "./i18n/ui-locale";
2324
import { DEFAULT_LOCALE } from "./constants";
25+
import { BASE_URL } from "./utils/env";
2426

2527
type NavigationContext = {
2628
url: URL;
@@ -157,6 +159,69 @@ export function changeMods(mods: string[]): void {
157159
);
158160
}
159161

162+
function stripBaseFromPathname(pathname: string): string {
163+
const baseNoSlash = BASE_URL.endsWith("/") ? BASE_URL.slice(0, -1) : BASE_URL;
164+
165+
if (pathname === baseNoSlash) return "/";
166+
if (pathname.startsWith(BASE_URL)) return pathname.slice(BASE_URL.length - 1);
167+
if (pathname.startsWith(baseNoSlash + "/"))
168+
return pathname.slice(baseNoSlash.length);
169+
return pathname;
170+
}
171+
172+
function hasVersionlessHomePath(pathname: string): boolean {
173+
const path = stripBaseFromPathname(pathname);
174+
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
175+
return cleanPath.length === 0;
176+
}
177+
178+
function buildCanonicalBootstrapURL(
179+
urlInput: string,
180+
builds: BuildsState,
181+
): string {
182+
const currentURL = new URL(urlInput, location.origin);
183+
184+
const canonicalPath = resolveVersionedPath(currentURL.pathname, builds);
185+
186+
const canonicalMods =
187+
page.route.modsParam.length > 0
188+
? page.route.modsParam
189+
: (preferences.defaultMods ?? []);
190+
191+
const canonicalLocale = page.route.localeParam;
192+
193+
const canonicalTileset = resolveTileset(
194+
preferences.preferredTileset,
195+
page.route.tilesetParam,
196+
);
197+
198+
const builtCanonicalURL = new URL(
199+
buildURL(
200+
canonicalPath.versionSlug,
201+
canonicalPath.target,
202+
canonicalLocale,
203+
canonicalTileset,
204+
canonicalMods,
205+
),
206+
location.origin,
207+
);
208+
209+
// Preserve the root home route shape while still normalizing query-backed context.
210+
if (
211+
hasVersionlessHomePath(currentURL.pathname) &&
212+
canonicalPath.target.kind === "home"
213+
) {
214+
builtCanonicalURL.pathname = currentURL.pathname;
215+
}
216+
217+
builtCanonicalURL.hash = currentURL.hash;
218+
return (
219+
builtCanonicalURL.pathname +
220+
builtCanonicalURL.search +
221+
builtCanonicalURL.hash
222+
);
223+
}
224+
160225
/**
161226
* Initializes the navigation prerequisites before the app shell mounts.
162227
*
@@ -167,29 +232,11 @@ export function changeMods(mods: string[]): void {
167232
export async function bootstrapApplication(): Promise<void> {
168233
initializeRouting();
169234
initializePreferences();
170-
171-
if (
172-
page.route.modsParam.length === 0 &&
173-
preferences.defaultMods !== null &&
174-
preferences.defaultMods.length > 0
175-
) {
176-
const urlWithMods = buildURL(
177-
page.route.versionSlug,
178-
page.route.target,
179-
page.route.localeParam,
180-
page.route.tilesetParam,
181-
preferences.defaultMods,
182-
);
183-
navigateToURL(urlWithMods, "replace");
184-
}
185-
186235
const versionState = await initializeBuildsState();
236+
const canonicalURL = buildCanonicalBootstrapURL(location.href, versionState);
237+
const currentURL = location.pathname + location.search + location.hash;
187238

188-
const canonicalURL = canonicalizeMalformedVersionURL(
189-
location.href,
190-
versionState,
191-
);
192-
if (canonicalURL) {
239+
if (canonicalURL !== currentURL) {
193240
navigateToURL(canonicalURL, "replace");
194241
}
195242

src/navigation.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,4 +289,37 @@ describe("navigation", () => {
289289
);
290290
expect(page.route.modsParam).toEqual([]);
291291
});
292+
293+
test("bootstrap canonicalizes version and saved preferences with one replaceState", async () => {
294+
localStorage.setItem("cbn-guide:tileset", "retrodays");
295+
localStorage.setItem(
296+
"cbn-guide:default-mods",
297+
JSON.stringify(["aftershock"]),
298+
);
299+
setWindowLocation("bogus/item/rock");
300+
_resetRouting();
301+
const replaceStateSpy = vi
302+
.spyOn(history, "replaceState")
303+
.mockImplementation((_, __, url) => {
304+
const nextUrl = new URL(String(url), window.location.origin);
305+
window.location.pathname = nextUrl.pathname;
306+
window.location.search = nextUrl.search;
307+
window.location.href = nextUrl.toString();
308+
});
309+
310+
await bootstrapApplication();
311+
312+
expect(replaceStateSpy).toHaveBeenCalledOnce();
313+
expect(replaceStateSpy).toHaveBeenCalledWith(
314+
null,
315+
"",
316+
"/nightly/item/rock?t=retrodays&mods=aftershock",
317+
);
318+
expect(page.route).toMatchObject({
319+
versionSlug: "nightly",
320+
tilesetParam: "retrodays",
321+
modsParam: ["aftershock"],
322+
target: { kind: "item", type: "item", id: "rock" },
323+
});
324+
});
292325
});

src/routing.svelte.ts

Lines changed: 17 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import { BASE_URL } from "./utils/env";
88
import {
9-
buildsState,
109
type BuildsState,
1110
NIGHTLY_VERSION,
1211
STABLE_VERSION,
@@ -40,7 +39,6 @@ type PageState = {
4039
url: URL;
4140
route: URLRoute;
4241
};
43-
4442
// ============================================================================
4543
// Internal Helper Functions
4644
// ============================================================================
@@ -134,24 +132,6 @@ function isRouteHeadSegment(segment: string | undefined): boolean {
134132
segment === "search" || (segment !== undefined && isSupportedType(segment))
135133
);
136134
}
137-
138-
function buildPathnameFromSegments(segments: string[]): string {
139-
const basePath = BASE_URL.endsWith("/") ? BASE_URL : `${BASE_URL}/`;
140-
141-
if (segments.length === 0) {
142-
return basePath;
143-
}
144-
145-
const encodedPath = segments.map(encodeURIComponent).join("/");
146-
return segments.length === 1
147-
? `${basePath}${encodedPath}/`
148-
: `${basePath}${encodedPath}`;
149-
}
150-
151-
function buildRelativeURL(url: URL, segments: string[]): string {
152-
return buildPathnameFromSegments(segments) + url.search + url.hash;
153-
}
154-
155135
/**
156136
* Canonicalize malformed version routes once build metadata is available.
157137
*
@@ -162,33 +142,39 @@ function buildRelativeURL(url: URL, segments: string[]): string {
162142
* The bare home route remains untouched, and any existing query string or hash
163143
* fragment is preserved in the returned URL.
164144
*
165-
* @param urlInput - Browser URL to inspect and potentially canonicalize
145+
* @param pathname - Browser pathname to inspect and potentially canonicalize
166146
* @param builds - Loaded build metadata used to validate version slugs
167147
* @returns Canonical relative URL when a rewrite is needed, otherwise `null`
168148
*/
169-
export function canonicalizeMalformedVersionURL(
170-
urlInput: string,
149+
export function resolveVersionedPath(
150+
pathname: string,
171151
builds: BuildsState,
172-
): string | null {
173-
const url = new URL(urlInput, location.origin);
174-
const segments = getPathSegmentsFromPath(url.pathname);
152+
): Pick<URLRoute, "versionSlug" | "target"> {
153+
const segments = getPathSegmentsFromPath(pathname);
175154

176155
if (segments.length === 0) {
177-
return null;
156+
return {
157+
versionSlug: STABLE_VERSION,
158+
target: { kind: "home" },
159+
};
178160
}
179161

180162
const [firstSegment, ...restSegments] = segments;
181163
if (tryResolveBuildVersion(firstSegment, builds) !== undefined) {
182-
return null;
164+
return {
165+
versionSlug: firstSegment,
166+
target: createRouteTarget(segments[1], segments[2]),
167+
};
183168
}
184169

185170
const rewrittenSegments = isRouteHeadSegment(firstSegment)
186171
? [NIGHTLY_VERSION, ...segments]
187172
: [NIGHTLY_VERSION, ...restSegments];
188-
const canonicalURL = buildRelativeURL(url, rewrittenSegments);
189-
const currentURL = url.pathname + url.search + url.hash;
190173

191-
return canonicalURL === currentURL ? null : canonicalURL;
174+
return {
175+
versionSlug: NIGHTLY_VERSION,
176+
target: createRouteTarget(rewrittenSegments[1], rewrittenSegments[2]),
177+
};
192178
}
193179

194180
const LOCALE_PARAM_NAME = "lang";
@@ -240,16 +226,6 @@ function ensurePopstateHandlerInstalled(): void {
240226
}
241227

242228
popstateHandler = () => {
243-
if (buildsState.current) {
244-
const canonicalURL = canonicalizeMalformedVersionURL(
245-
location.href,
246-
buildsState.current,
247-
);
248-
if (canonicalURL) {
249-
navigateToURL(canonicalURL, "replace");
250-
return;
251-
}
252-
}
253229
updatePageState();
254230
};
255231

src/routing.test.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -389,36 +389,4 @@ describe("App routing integration", () => {
389389
expectVisibleText(/rock/i);
390390
});
391391
});
392-
393-
test("canonicalizes malformed popstate version URLs with replaceState", async () => {
394-
await renderApp();
395-
396-
await waitForDataLoad();
397-
const replaceStateSpy = vi
398-
.spyOn(history, "replaceState")
399-
.mockImplementation((_, __, url) => {
400-
const nextUrl = new URL(String(url), window.location.origin);
401-
window.location.pathname = nextUrl.pathname;
402-
window.location.search = nextUrl.search;
403-
window.location.href = nextUrl.toString();
404-
});
405-
406-
await act(async () => {
407-
setWindowLocation("bogus/item/rock", "?mods=aftershock");
408-
dispatchPopState();
409-
});
410-
411-
await waitFor(() =>
412-
expect(window.location.pathname).toBe("/nightly/item/rock"),
413-
);
414-
expect(replaceStateSpy).toHaveBeenCalledWith(
415-
null,
416-
"",
417-
"/nightly/item/rock?mods=aftershock",
418-
);
419-
await waitFor(() => {
420-
expectVisibleText(/rock/i);
421-
});
422-
replaceStateSpy.mockRestore();
423-
});
424392
});

src/routing.url.test.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import {
1515
import {
1616
_resetRouting,
1717
buildURL,
18-
canonicalizeMalformedVersionURL,
1918
handleInternalNavigation,
2019
initializeRouting,
2120
page,
2221
parseRoute,
22+
resolveVersionedPath,
2323
} from "./routing.svelte";
2424
import {
2525
createBuildsFetchMock,
@@ -242,33 +242,49 @@ describe("routing URL logic", () => {
242242
});
243243

244244
describe("malformed version URL canonicalization", () => {
245-
test.each([
246-
["http://localhost/monster/zombie", "/nightly/monster/zombie"],
247-
["http://localhost/item", "/nightly/item"],
248-
["http://localhost/search/rock", "/nightly/search/rock"],
249-
])("canonicalizes missing version route %s", async (input, expected) => {
245+
test("resolves an intact versioned path without rewriting it", async () => {
250246
const state = await initializeBuildsState();
251247

252-
expect(canonicalizeMalformedVersionURL(input, state)).toBe(expected);
248+
expect(resolveVersionedPath("/stable/item/rock", state)).toEqual({
249+
versionSlug: "stable",
250+
target: { kind: "item", type: "item", id: "rock" },
251+
});
253252
});
254253

255-
test("canonicalizes invalid explicit versions and preserves query and hash", async () => {
254+
test("resolves missing-version deep links by correcting only the path", async () => {
256255
const state = await initializeBuildsState();
257256

258-
expect(
259-
canonicalizeMalformedVersionURL(
260-
"http://localhost/bad/item/rock?lang=ru_RU&t=retrodays&mods=aftershock#lore",
261-
state,
262-
),
263-
).toBe("/nightly/item/rock?lang=ru_RU&t=retrodays&mods=aftershock#lore");
257+
expect(resolveVersionedPath("/monster/zombie", state)).toEqual({
258+
versionSlug: "nightly",
259+
target: { kind: "item", type: "monster", id: "zombie" },
260+
});
261+
});
262+
263+
test("resolves missing-version catalog links", async () => {
264+
const state = await initializeBuildsState();
265+
266+
expect(resolveVersionedPath("/monster", state)).toEqual({
267+
versionSlug: "nightly",
268+
target: { kind: "catalog", type: "monster" },
269+
});
270+
});
271+
272+
test("resolves missing-version search links", async () => {
273+
const state = await initializeBuildsState();
274+
275+
expect(resolveVersionedPath("/search/answer", state)).toEqual({
276+
versionSlug: "nightly",
277+
target: { kind: "search", query: "answer" },
278+
});
264279
});
265280

266281
test("keeps the bare home URL untouched", async () => {
267282
const state = await initializeBuildsState();
268283

269-
expect(
270-
canonicalizeMalformedVersionURL("http://localhost/", state),
271-
).toBeNull();
284+
expect(resolveVersionedPath("/", state)).toEqual({
285+
versionSlug: "stable",
286+
target: { kind: "home" },
287+
});
272288
});
273289
});
274290

0 commit comments

Comments
 (0)