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:
@@ -0,0 +1,3 @@
|
||||
# Cloudflare Pages: static files win; these cover History deep links without a file.
|
||||
/heroes/:key/:tab /heroes/:key/index.html 200
|
||||
/* /index.html 200
|
||||
+137
-2
@@ -1,4 +1,4 @@
|
||||
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */
|
||||
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */
|
||||
|
||||
function trimBase(raw) {
|
||||
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
|
||||
@@ -76,6 +76,138 @@ function siteVersionLabel() {
|
||||
return v ? `v${v}` : "";
|
||||
}
|
||||
|
||||
function siteOrigin() {
|
||||
const configured = typeof SITE_ORIGIN === "string" ? SITE_ORIGIN.trim().replace(/\/+$/, "") : "";
|
||||
if (configured) return configured;
|
||||
if (typeof location !== "undefined" && location.origin) return location.origin;
|
||||
return "";
|
||||
}
|
||||
|
||||
const PAGE_SEO_LABELS = {
|
||||
heroes: "英雄克制与搭档",
|
||||
rankings: "Immortal 排行",
|
||||
streamers: "主播",
|
||||
matches: "明星比赛",
|
||||
trends: "近 8 周走势",
|
||||
mechanics: "机制查询",
|
||||
items: "物品商店",
|
||||
patches: "版本更新",
|
||||
};
|
||||
|
||||
const DETAIL_TAB_SEO_LABELS = {
|
||||
skills: "技能",
|
||||
core: "核心装",
|
||||
fears: "怕的装备",
|
||||
trends: "走势",
|
||||
matchups: "对位",
|
||||
matches: "近期比赛",
|
||||
streamers: "主播",
|
||||
patches: "版本改动",
|
||||
};
|
||||
|
||||
function setMetaByKey(attr, key, content) {
|
||||
if (!content) return;
|
||||
let el = document.querySelector(`meta[${attr}="${key}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement("meta");
|
||||
el.setAttribute(attr, key);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute("content", content);
|
||||
}
|
||||
|
||||
function describeStateForSeo(st) {
|
||||
const brand = "上分帝";
|
||||
const page = st.page || "heroes";
|
||||
let title = `${PAGE_SEO_LABELS[page] || "DOTA2"} — ${brand}`;
|
||||
let description =
|
||||
"Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。";
|
||||
let path = "/" + page;
|
||||
try {
|
||||
if (typeof serializeHash === "function") path = serializeHash(st) || "/heroes";
|
||||
} catch (_) {
|
||||
/* keep path */
|
||||
}
|
||||
if (!st.data) return { title, description, path };
|
||||
|
||||
if (page === "heroes" && st.selectedKey) {
|
||||
const hero = heroByKey(st.selectedKey);
|
||||
const name = (hero && hero.name_loc) || st.selectedKey;
|
||||
const aliases = (hero && hero.aliases) || [];
|
||||
const tab = DETAIL_TAB_SEO_LABELS[st.detailTab] || "详情";
|
||||
title = `${name} ${tab} / 克制搭档 — ${brand}`;
|
||||
const aliasBit = aliases.length ? `(${aliases.slice(0, 3).join("、")})` : "";
|
||||
description = `${name}${aliasBit}的 Dota 2 机制克制、被克制与搭档参考,以及技能、出装、走势与对位数据。`;
|
||||
} else if (page === "mechanics") {
|
||||
const mq = st.data.mechanic_query || {};
|
||||
const labels = mq.labels || {};
|
||||
const effect = st.mechanicEffect || "basic_dispel";
|
||||
const label = labels[effect] || effect;
|
||||
title = `${label} — 机制查询 — ${brand}`;
|
||||
const blurb = (mq.blurbs && mq.blurbs[effect]) || "";
|
||||
description = blurb || `查询 Dota 2 中施加「${label}」的技能与物品。`;
|
||||
} else if (page === "items" && st.selectedItemKey) {
|
||||
const meta = shopItem(st.selectedItemKey);
|
||||
const name = (meta && (meta.name_loc || meta.dname)) || st.selectedItemKey;
|
||||
title = `${name} — 物品 — ${brand}`;
|
||||
description = `${name} 的合成、描述与机制标签(上分帝物品页)。`;
|
||||
} else if (page === "patches") {
|
||||
const ver =
|
||||
st.selectedPatch ||
|
||||
((st.data.patches && st.data.patches[0]) || {}).version;
|
||||
if (ver) {
|
||||
title = `版本 ${ver} — ${brand}`;
|
||||
description = `Dota 2 ${ver} 游戏性更新摘要(上分帝版本页)。`;
|
||||
}
|
||||
} else if (page === "trends") {
|
||||
title = `近 8 周走势 — ${brand}`;
|
||||
description = "各勋章段位近 8 周英雄胜率与上场率走势榜。";
|
||||
} else if (page === "rankings") {
|
||||
title = `Immortal 排行榜 — ${brand}`;
|
||||
description = "Valve Immortal 四区 Top100 选手榜。";
|
||||
} else if (page === "streamers") {
|
||||
title = `Dota 2 主播 — ${brand}`;
|
||||
description = "精选 Dota 2 主播目录与高光(抖音 / B 站 / 斗鱼)。";
|
||||
} else if (page === "matches") {
|
||||
title = `明星比赛 — ${brand}`;
|
||||
description = "明星选手近期职业与国服对局、终局出装与加点。";
|
||||
} else if (page === "heroes") {
|
||||
title = `英雄克制与搭档 — ${brand}`;
|
||||
description =
|
||||
"按英雄浏览定性克制 / 被克制 / 搭档理由,以及技能、核心装、走势与对位。";
|
||||
}
|
||||
|
||||
return { title, description, path };
|
||||
}
|
||||
|
||||
function updateDocumentMeta() {
|
||||
if (typeof document === "undefined" || !document.title) return;
|
||||
const { title, description, path } = describeStateForSeo(state);
|
||||
document.title = title;
|
||||
setMetaByKey("name", "description", description);
|
||||
setMetaByKey("property", "og:title", title);
|
||||
setMetaByKey("property", "og:description", description);
|
||||
setMetaByKey("name", "twitter:title", title);
|
||||
setMetaByKey("name", "twitter:description", description);
|
||||
const origin = siteOrigin();
|
||||
if (origin) {
|
||||
const url = origin + (path.startsWith("/") ? path : `/${path}`);
|
||||
setMetaByKey("property", "og:url", url);
|
||||
let link = document.querySelector('link[rel="canonical"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.setAttribute("rel", "canonical");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.setAttribute("href", url);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSeoPrerender() {
|
||||
const el = document.getElementById("seo-prerender");
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function renderPatchesSiteVersion() {
|
||||
const el = $("#patches-site-version");
|
||||
if (!el) return;
|
||||
@@ -5468,6 +5600,7 @@ function render() {
|
||||
} else {
|
||||
renderPatches();
|
||||
}
|
||||
updateDocumentMeta();
|
||||
}
|
||||
|
||||
// Search-box URL sync timers (debounced replace so typing does not spam history).
|
||||
@@ -5698,7 +5831,8 @@ async function main() {
|
||||
if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png");
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
if (favicon) favicon.href = assetUrl("/ui-icon/dota2_logo.png");
|
||||
const res = await fetch("data.json");
|
||||
// Absolute path: History routes like /heroes/axe must not resolve data.json relatively.
|
||||
const res = await fetch("/data.json");
|
||||
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
|
||||
state.data = await res.json();
|
||||
state.data.relations = state.data.relations || { counters: [], synergies: [] };
|
||||
@@ -5755,6 +5889,7 @@ async function main() {
|
||||
_innateAbilityKeys = null;
|
||||
installRouter({ getState: () => state, applyPatch });
|
||||
applyUrlToState();
|
||||
clearSeoPrerender();
|
||||
refreshStreamerLiveStatus();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||
var SITE_VERSION = "0.5.114";
|
||||
var SITE_VERSION = "0.5.115";
|
||||
var SITE_ORIGIN = "";
|
||||
var ABILITY_VIDEO_BASE = "";
|
||||
var STATIC_ASSET_BASE = "";
|
||||
|
||||
|
||||
+60
-6
@@ -3,13 +3,67 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DOTA2 上分帝</title>
|
||||
<title>DOTA2 上分帝 — 英雄克制 / 搭档 / 走势 / 机制</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="上分帝(Climperor):Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。定性关系理由 + 公开数据走势,助你上分。"
|
||||
/>
|
||||
<meta name="keywords" content="DOTA2,上分帝,Climperor,英雄克制,搭档,胜率走势,机制查询,驱散,版本更新" />
|
||||
<link rel="canonical" href="https://dota2.refining.dev/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="上分帝" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
<meta property="og:title" content="DOTA2 上分帝 — 英雄克制 / 搭档 / 走势 / 机制" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。"
|
||||
/>
|
||||
<meta property="og:url" content="https://dota2.refining.dev/" />
|
||||
<meta property="og:image" content="https://climperor.oss-cn-shanghai.aliyuncs.com/ui-icon/dota2_logo_wordmark.png" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="DOTA2 上分帝" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。"
|
||||
/>
|
||||
<script type="application/ld+json" id="seo-jsonld">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "上分帝",
|
||||
"alternateName": ["Climperor", "DOTA2 上分帝"],
|
||||
"url": "https://dota2.refining.dev/",
|
||||
"inLanguage": "zh-CN",
|
||||
"description": "Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://dota2.refining.dev/heroes?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.114" />
|
||||
<script src="/mobile-gate.js?v=0.5.114"></script>
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.115" />
|
||||
<script src="/mobile-gate.js?v=0.5.115"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||
<aside id="seo-prerender" class="seo-prerender">
|
||||
<p>
|
||||
上分帝(Climperor)是面向 Dota 2 的选将与机制参考站:浏览英雄克制与搭档理由、
|
||||
段位走势、机制查询(驱散与控制)、物品商店与版本更新。请使用桌面浏览器访问完整交互界面。
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="/heroes">英雄克制与搭档</a></li>
|
||||
<li><a href="/mechanics">机制查询</a></li>
|
||||
<li><a href="/trends">近 8 周走势</a></li>
|
||||
<li><a href="/items">物品商店</a></li>
|
||||
<li><a href="/patches">版本更新</a></li>
|
||||
<li><a href="/rankings">Immortal 排行</a></li>
|
||||
<li><a href="/streamers">主播</a></li>
|
||||
<li><a href="/matches">明星比赛</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
|
||||
<div class="mobile-gate-card">
|
||||
<div class="mobile-gate-brand" aria-hidden="true">
|
||||
@@ -178,8 +232,8 @@
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/config.js?v=0.5.114"></script>
|
||||
<script src="/router.js?v=0.5.114"></script>
|
||||
<script src="/app.js?v=0.5.114"></script>
|
||||
<script src="/config.js?v=0.5.115"></script>
|
||||
<script src="/router.js?v=0.5.115"></script>
|
||||
<script src="/app.js?v=0.5.115"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
* Early mobile client gate (loaded from <head>).
|
||||
* Sets html.mobile-client before paint; wires the demand button after DOM ready.
|
||||
* Exposes window.__CLIMPEROR_MOBILE__ for app.js to skip the desktop boot path.
|
||||
* Search / AI crawlers skip the gate so prerendered HTML stays indexable.
|
||||
*/
|
||||
(function () {
|
||||
function isCrawler() {
|
||||
var ua = navigator.userAgent || "";
|
||||
return /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora link preview|pinterest|redditbot|linkedinbot|twitterbot|whatsapp|google-inspectiontool|bytespider|baiduspider|yandex|duckduckbot|applebot|semrush|ahrefs|gptbot|claudebot|anthropic|perplexity|oai-searchbot|chatgpt/i.test(
|
||||
ua
|
||||
);
|
||||
}
|
||||
|
||||
function isMobileClient() {
|
||||
var ua = navigator.userAgent || "";
|
||||
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
|
||||
@@ -16,6 +24,11 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCrawler()) {
|
||||
window.__CLIMPEROR_MOBILE__ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var mobile = isMobileClient();
|
||||
window.__CLIMPEROR_MOBILE__ = mobile;
|
||||
if (!mobile) return;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://dota2.refining.dev/sitemap.xml
|
||||
+65
-44
@@ -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);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,8 @@ body {
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sr-only {
|
||||
.sr-only,
|
||||
.seo-prerender {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
|
||||
Reference in New Issue
Block a user