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
+137 -2
View File
@@ -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();
}