v0.5.115: add History routing and SEO prerender for Climperor Web.

Path URLs, crawlable hero/mechanics pages, and sitemap make the static site indexable while keeping SPA hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 04:55:33 +08:00
co-authored by Cursor
parent 38f46ad2ea
commit 544ea42d40
16 changed files with 838 additions and 81 deletions
+65 -44
View File
@@ -1,38 +1,43 @@
/* global window, history, location */
/**
* Hash-based router for the Climperor web site (web/relations).
* Path-based router for the Climperor web site (web/frontend).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches (top-level tab)
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches
* - hero: selected hero key + detail sub-tab
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats → trends)
* - rankings: Immortal leaderboard region
* #/rankings[/region] (legacy #/rankings/meta[/bracket] → players / china)
* /rankings[/region] (legacy /rankings/meta[/bracket] → players / china)
* - matches: star-player recent matches (pro_matches)
* #/matches[/account_id][?origin=pro|china][&page=N]
* /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - streamers: curated Douyin streamer directory
* #/streamers
* - streamers: curated streamer directory
* /streamers
* - trends: medal bracket for the 8-week win/pick board
* #/trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
* /trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
* - mechanics: applies-effect query (dispel / CC)
* #/mechanics[/{effect}] (default effect basic_dispel omitted)
* /mechanics[/{effect}] (default effect basic_dispel omitted)
* - item: selected shop item key (items page)
* - patch: selected version string (patches page; latest when absent)
* - query params: tags=csv (heroes), q=search text (heroes + items)
*
* Legacy hash URLs (#/heroes/...) are migrated once to path URLs via replaceState.
*
* Two-way binding without feedback loops:
* - App calls syncStateToUrl() after each state mutation. We use
* history.pushState/replaceState, which update the URL silently
* (no hashchange event fires) — so applyUrlToState is never re-entered.
* - Back/forward navigation fires hashchange → applyUrlToState parses
* the new hash, hands a validated patch back to app, and app re-renders.
* (no popstate) — so applyUrlToState is never re-entered.
* - Back/forward navigation fires popstate → applyUrlToState parses
* the new path, hands a validated patch back to app, and app re-renders.
*
* API names parseHash / serializeHash are kept for call-site stability; both
* now operate on pathname + search (not location.hash).
*
* Depends on app.js for state shape and render(); installed via installRouter().
*/
const ROUTE_DEFAULT = "#/heroes";
const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
const VALID_DETAIL_TABS = [
"skills",
@@ -85,11 +90,20 @@ let _deps = null;
*/
function installRouter(deps) {
_deps = deps;
window.addEventListener("hashchange", applyUrlToState);
migrateLegacyHashRoute();
window.addEventListener("popstate", applyUrlToState);
}
/** Parse a hash string into a state patch (all fields nullable). */
function parseHash(hash) {
/** One-shot: #/heroes/... → /heroes/... (bookmarks / shared links). */
function migrateLegacyHashRoute() {
const hash = window.location.hash || "";
if (!hash.startsWith("#/")) return;
const next = hash.slice(1) || ROUTE_DEFAULT;
history.replaceState(null, "", next);
}
/** Parse a path (+ optional query) into a state patch (all fields nullable). */
function parseHash(hashOrPath) {
const out = {
page: null,
heroKey: null,
@@ -107,14 +121,17 @@ function parseHash(hash) {
query: null,
itemQuery: null,
};
let raw = hash || "";
let raw = hashOrPath || "";
if (raw.startsWith("#")) raw = raw.slice(1);
if (!raw.startsWith("/")) raw = "/" + raw;
const qIdx = raw.indexOf("?");
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : "";
const segs = pathPart.split("/").filter(Boolean);
if (!segs.length) return out;
if (!segs.length) {
out.page = "heroes";
return out;
}
const page = segs[0];
if (!VALID_PAGES.includes(page)) return out;
out.page = page;
@@ -122,7 +139,7 @@ function parseHash(hash) {
if (segs[1]) out.heroKey = safeDecode(segs[1]);
if (segs[2]) out.detailTab = safeDecode(segs[2]);
} else if (page === "rankings") {
// Legacy #/rankings/meta[/bracket] → Immortal players board (default region).
// Legacy /rankings/meta[/bracket] → Immortal players board (default region).
if (segs[1] && segs[1] !== "meta") {
out.rankingRegion = safeDecode(segs[1]);
}
@@ -179,56 +196,56 @@ function safeDecode(s) {
}
}
/** Serialize current app state into a hash string (with leading '#'). */
/** Serialize current app state into a path string (leading '/', optional ?query). */
function serializeHash(state) {
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
let hash = "#/" + state.page;
let path = "/" + state.page;
if (state.page === "heroes") {
if (state.selectedKey) {
hash += "/" + encodeURIComponent(state.selectedKey);
path += "/" + encodeURIComponent(state.selectedKey);
let tab = state.detailTab;
if (tab === "stats") tab = "trends"; // normalize legacy alias in URL
if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") {
hash += "/" + encodeURIComponent(tab);
path += "/" + encodeURIComponent(tab);
}
}
} else if (state.page === "rankings") {
// Omit region when it is the default (china) — bare #/rankings means China.
// Omit region when it is the default (china) — bare /rankings means China.
const region = state.rankingRegion || DEFAULT_RANKING_REGION;
if (region && region !== DEFAULT_RANKING_REGION) {
hash += "/" + encodeURIComponent(region);
path += "/" + encodeURIComponent(region);
}
} else if (state.page === "matches") {
if (state.matchesPlayerId) {
hash += "/" + encodeURIComponent(String(state.matchesPlayerId));
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
}
} else if (state.page === "trends") {
// Omit bracket when it is the default (legend) — bare #/trends means legend.
// Omit bracket when it is the default (legend) — bare /trends means legend.
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) {
hash += "/" + encodeURIComponent(bracket);
path += "/" + encodeURIComponent(bracket);
}
const sort = state.trendsSort || DEFAULT_TRENDS_SORT;
if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) {
hash += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort);
path += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort);
}
} else if (state.page === "mechanics") {
const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT;
if (effect && effect !== DEFAULT_MECHANIC_EFFECT) {
hash += "/" + encodeURIComponent(effect);
path += "/" + encodeURIComponent(effect);
}
} else if (state.page === "items") {
if (state.selectedItemKey) {
hash += "/" + encodeURIComponent(state.selectedItemKey);
path += "/" + encodeURIComponent(state.selectedItemKey);
}
} else if (state.page === "patches") {
// Omit version when it is the latest — bare #/patches means "latest".
// Omit version when it is the latest — bare /patches means "latest".
if (state.selectedPatch) {
const patches = (state.data && state.data.patches) || [];
const isLatest =
patches.length > 0 && patches[0].version === state.selectedPatch;
if (!isLatest) {
hash += "/" + encodeURIComponent(state.selectedPatch);
path += "/" + encodeURIComponent(state.selectedPatch);
}
}
}
@@ -239,12 +256,12 @@ function serializeHash(state) {
}
if (state.query) qs.set("q", state.query);
const s = qs.toString();
if (s) hash += "?" + s;
if (s) path += "?" + s;
} else if (state.page === "items") {
const qs = new URLSearchParams();
if (state.itemQuery) qs.set("q", state.itemQuery);
const s = qs.toString();
if (s) hash += "?" + s;
if (s) path += "?" + s;
} else if (state.page === "matches") {
const qs = new URLSearchParams();
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
@@ -254,29 +271,33 @@ function serializeHash(state) {
const pageNum = Number(state.matchesPage) || 1;
if (pageNum > 1) qs.set("page", String(pageNum));
const s = qs.toString();
if (s) hash += "?" + s;
if (s) path += "?" + s;
}
return hash;
return path;
}
function currentRoutePath() {
return (window.location.pathname || "/") + (window.location.search || "");
}
/** Write current state into the URL. Use replace:true for high-frequency
* updates (search typing, tag toggles) to avoid history-stack spam. */
function syncStateToUrl({ replace = false } = {}) {
if (!_deps) return;
const hash = serializeHash(_deps.getState());
if (hash === window.location.hash) return; // no-op when state matches URL
const path = serializeHash(_deps.getState());
if (path === currentRoutePath()) return; // no-op when state matches URL
if (replace) {
history.replaceState(null, "", hash);
history.replaceState(null, "", path);
} else {
history.pushState(null, "", hash);
history.pushState(null, "", path);
}
}
/** Parse current location.hash, hand it to app for validation+merge, render.
* Invoked on hashchange (back/forward / direct location.hash writes) and
* once at startup for deep-link support. */
/** Parse current location path+search, hand it to app for validation+merge.
* Invoked on popstate (back/forward) and once at startup for deep-link support. */
function applyUrlToState() {
if (!_deps) return;
const patch = parseHash(window.location.hash);
const path = currentRoutePath();
const patch = parseHash(path === "/" ? "/heroes" : path);
_deps.applyPatch(patch);
}