diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08fa41a --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Deployment target: "com" or "cn". Builds default to "com" when omitted. +SITE_VARIANT=com + +# Build-only secrets used to resolve node IPs into public coordinates. +# Never prefix these variables with VITE_, or they may be exposed to the browser. +KOMARI_BASE_URL=https://ops.zhouhaoze.top +KOMARI_API_KEY= +IPINFO_TOKEN= diff --git a/build/node-locations.ts b/build/node-locations.ts new file mode 100644 index 0000000..dc01055 --- /dev/null +++ b/build/node-locations.ts @@ -0,0 +1,215 @@ +import { isIP } from "node:net"; + +interface KomariRpcResponse { + result?: Record; + error?: { code: number; message: string }; +} + +interface KomariNode { + uuid: string; + name: string; + ipv4?: string; + ipv6?: string; + hidden?: boolean; + region?: string; +} + +interface KomariNodesResponse { + data?: KomariNode[]; +} + +export interface PublicNode { + uuid: string; + name: string; + region: string; +} + +interface GeoResult { + lat: number; + lng: number; + city: string; + country: string; + countryCode: string; +} + +export interface NodeLocation extends GeoResult { + uuid: string; + name: string; +} + +export interface NodeLocationsFile { + version: 1; + generatedAt: string | null; + nodes: PublicNode[]; + locations: NodeLocation[]; +} + +export interface NodeLocationBuildOptions { + komariBaseUrl: string; + komariApiKey?: string; + ipinfoToken?: string; +} + +function isPrivateIp(ip: string): boolean { + if (ip.includes(":")) { + const value = ip.toLowerCase(); + return value === "::1" || value.startsWith("fc") || value.startsWith("fd") || value.startsWith("fe80:"); + } + + const parts = ip.split(".").map(Number); + return ( + parts[0] === 10 || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) + ); +} + +function firstPublicIp(node: KomariNode): string | null { + for (const rawValue of [node.ipv4, node.ipv6]) { + if (!rawValue) continue; + for (const rawCandidate of rawValue.split(/[\s,]+/)) { + const candidate = rawCandidate.replace(/^\[|\]$/g, "").split("/")[0]; + if (isIP(candidate) && !isPrivateIp(candidate)) return candidate; + } + } + return null; +} + +async function fetchJson(url: string, init?: RequestInit): Promise> { + const response = await fetch(url, { + ...init, + headers: { + Accept: "application/json", + "User-Agent": "PersonalWeb build", + ...init?.headers, + }, + signal: AbortSignal.timeout(7000), + }); + if (!response.ok) throw new Error(`${url} responded ${response.status}`); + return response.json() as Promise>; +} + +function numberValue(value: unknown): number | null { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function geoResult( + latValue: unknown, + lngValue: unknown, + city: unknown, + country: unknown, + countryCode: unknown, +): GeoResult | null { + const lat = numberValue(latValue); + const lng = numberValue(lngValue); + if (lat === null || lng === null || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; + return { + lat, + lng, + city: typeof city === "string" ? city : "", + country: typeof country === "string" ? country : "", + countryCode: typeof countryCode === "string" ? countryCode : "", + }; +} + +async function locateWithIpWho(ip: string): Promise { + const data = await fetchJson( + `https://ipwho.is/${encodeURIComponent(ip)}?fields=success,country,country_code,city,latitude,longitude`, + ); + if (data.success !== true) return null; + return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code); +} + +async function locateWithIpSb(ip: string): Promise { + const data = await fetchJson(`https://api.ip.sb/geoip/${encodeURIComponent(ip)}`); + return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code); +} + +async function locateWithIpinfo(ip: string, token: string): Promise { + const data = await fetchJson(`https://ipinfo.io/${encodeURIComponent(ip)}/json?token=${encodeURIComponent(token)}`); + if (typeof data.loc !== "string") return null; + const [lat, lng] = data.loc.split(","); + return geoResult(lat, lng, data.city, data.country, data.country); +} + +async function locateIp(ip: string, ipinfoToken?: string): Promise { + const providers = [() => locateWithIpWho(ip), () => locateWithIpSb(ip)]; + if (ipinfoToken) providers.push(() => locateWithIpinfo(ip, ipinfoToken)); + + for (const provider of providers) { + try { + const location = await provider(); + if (location) return location; + } catch { + // Try the next provider; the country-level fallback remains available in the client. + } + } + return null; +} + +async function mapWithConcurrency(items: T[], limit: number, worker: (item: T) => Promise): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + async function run(): Promise { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await worker(items[index]); + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())); + return results; +} + +async function fetchKomariNodes(baseUrl: string, apiKey: string): Promise { + const payload = await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/rpc2`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "common:getNodes", params: {} }), + }); + const rpc = payload as KomariRpcResponse; + if (rpc.error) throw new Error(`Komari RPC ${rpc.error.code}: ${rpc.error.message}`); + if (!rpc.result || Array.isArray(rpc.result)) throw new Error("Komari RPC returned an invalid node map."); + return Object.values(rpc.result).filter((node) => !node.hidden); +} + +async function fetchPublicNodes(baseUrl: string): Promise { + const payload = (await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/nodes`)) as KomariNodesResponse; + if (!Array.isArray(payload.data)) throw new Error("Komari public API returned an invalid node list."); + return payload.data.map((node) => ({ uuid: node.uuid, name: node.name, region: node.region ?? "" })); +} + +export async function generateNodeLocations(options: NodeLocationBuildOptions): Promise { + const publicNodes = await fetchPublicNodes(options.komariBaseUrl); + if (!options.komariApiKey) { + return { version: 1, generatedAt: new Date().toISOString(), nodes: publicNodes, locations: [] }; + } + + const nodes = await fetchKomariNodes(options.komariBaseUrl, options.komariApiKey); + const nodesWithIp = nodes.flatMap((node) => { + const ip = firstPublicIp(node); + return ip ? [{ node, ip }] : []; + }); + const uniqueIps = [...new Set(nodesWithIp.map(({ ip }) => ip))]; + const resolved = await mapWithConcurrency(uniqueIps, 4, async (ip) => [ip, await locateIp(ip, options.ipinfoToken)] as const); + const locationsByIp = new Map(resolved.filter((entry): entry is readonly [string, GeoResult] => entry[1] !== null)); + + const locations = nodesWithIp.flatMap(({ node, ip }) => { + const location = locationsByIp.get(ip); + return location ? [{ uuid: node.uuid, name: node.name, ...location }] : []; + }); + + return { + version: 1, + generatedAt: new Date().toISOString(), + nodes: publicNodes, + locations, + }; +} diff --git a/index.html b/index.html index 86582be..b2b51d6 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + @@ -7,33 +7,33 @@ - Haoze Zhou | Computer Science Student & Developer - - + %SITE_TITLE% + + - + - - - - - - - - + + + + + + + + - - - + + +
diff --git a/public/baidu_verify_codeva-uldEo3UuMb.html b/public/baidu_verify_codeva-uldEo3UuMb.html deleted file mode 100644 index 2b571b7..0000000 --- a/public/baidu_verify_codeva-uldEo3UuMb.html +++ /dev/null @@ -1 +0,0 @@ -538b0c38e87859dfdd9621f3f5f3d26b \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 9f5c78c..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,5 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://www.zhouhaoze.com/sitemap.xml -Sitemap: https://www.zhouhaoze.cn/sitemap-cn.xml diff --git a/public/sitemap-cn.xml b/public/sitemap-cn.xml deleted file mode 100644 index bea2a28..0000000 --- a/public/sitemap-cn.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - https://www.zhouhaoze.cn/ - 2026-08-13 - - diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index aa70712..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - https://www.zhouhaoze.com/ - 2026-08-13 - - diff --git a/src/data/contacts.ts b/src/data/contacts.ts index bfc2f6b..52f86e3 100644 --- a/src/data/contacts.ts +++ b/src/data/contacts.ts @@ -1,3 +1,5 @@ +import { IS_CN_SITE } from "../site-config"; + export interface Contact { icon: string; labelKey: string; @@ -8,5 +10,7 @@ export interface Contact { export const contacts: Contact[] = [ { icon: "ph-envelope-simple", labelKey: "contact.email", href: "mailto:me@zhouhaoze.com" }, { icon: "ph-github-logo", labelKey: "contact.github", href: "https://github.com/YIYI-16", external: true }, - { icon: "ph-telegram-logo", labelKey: "contact.telegram", href: "https://t.me/yiyitelegram_bot", external: true }, + ...(!IS_CN_SITE + ? [{ icon: "ph-telegram-logo", labelKey: "contact.telegram", href: "https://t.me/yiyitelegram_bot", external: true }] + : []), ]; diff --git a/src/globe.ts b/src/globe.ts index c91669d..c6d4e08 100644 --- a/src/globe.ts +++ b/src/globe.ts @@ -16,12 +16,47 @@ interface GlobePoint { } function buildPoints(snapshot: InfraSnapshot): GlobePoint[] { + const locationsById = new Map(snapshot.nodeLocations.map((location) => [location.uuid, location])); + const points = snapshot.nodes.flatMap((node) => { + const location = locationsById.get(node.uuid); + if (location) { + const place = [location.city, location.country].filter(Boolean).join(", "); + return [{ lat: location.lat, lng: location.lng, name: place ? `${node.name} · ${place}` : node.name, count: 1 }]; + } + const fallback = flagToRegionInfo(node.region); + return fallback ? [{ lat: fallback.lat, lng: fallback.lng, name: node.name, count: 1 }] : []; + }); + + if (points.length) return spreadOverlappingPoints(points); return snapshot.regionBreakdown.flatMap(({ flag, count }) => { const info = flagToRegionInfo(flag); return info ? [{ lat: info.lat, lng: info.lng, name: info.name, count }] : []; }); } +function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] { + const groups = new Map(); + for (const point of points) { + const key = `${point.lat.toFixed(3)},${point.lng.toFixed(3)}`; + const group = groups.get(key) ?? []; + group.push(point); + groups.set(key, group); + } + + return Array.from(groups.values()).flatMap((group) => { + if (group.length === 1) return group; + return group.map((point, index) => { + const angle = (index / group.length) * Math.PI * 2; + const radius = 0.28 + Math.floor(index / 8) * 0.12; + return { + ...point, + lat: Math.max(-89.9, Math.min(89.9, point.lat + Math.sin(angle) * radius)), + lng: point.lng + Math.cos(angle) * radius, + }; + }); + }); +} + function cssVar(name: string, fallback: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || fallback; @@ -61,6 +96,8 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () container.replaceChildren(renderer.domElement); renderer.domElement.style.cursor = "grab"; renderer.domElement.setAttribute("aria-label", "Interactive globe showing server regions"); + renderer.domElement.dataset.nodeCount = String(points.length); + renderer.domElement.dataset.preciseLocationCount = String(snapshot.nodeLocations.length); scene.add(new THREE.AmbientLight(0xffffff, 1.1)); const frontLight = new THREE.DirectionalLight(0xffffff, 0.65); @@ -97,7 +134,10 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () .labelsData(points) .labelLat("lat") .labelLng("lng") - .labelText((d) => `${(d as GlobePoint).name} · ${(d as GlobePoint).count}`) + .labelText((d) => { + const point = d as GlobePoint; + return point.count > 1 ? `${point.name} · ${point.count}` : point.name; + }) .labelSize(1.15) .labelColor(() => textColor) .labelDotRadius(0.32) diff --git a/src/i18n.ts b/src/i18n.ts index d036a17..e39dc9b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,6 +1,7 @@ import en from "./locales/en.json"; import zh from "./locales/zh.json"; import { updateSeo } from "./seo"; +import { IS_CN_SITE } from "./site-config"; export type Lang = "en" | "zh"; @@ -9,7 +10,7 @@ const dictionaries: Record> = { en, zh }; const STORAGE_KEY = "pw-lang"; function detectLang(): Lang { - if (window.location.hostname.endsWith(".cn")) return "zh"; + if (IS_CN_SITE) return "zh"; const stored = localStorage.getItem(STORAGE_KEY); if (stored === "en" || stored === "zh") return stored; return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en"; diff --git a/src/komari.ts b/src/komari.ts index 7bcbf94..4385d01 100644 --- a/src/komari.ts +++ b/src/komari.ts @@ -3,6 +3,7 @@ import { infraFallback } from "./data/infra"; const KOMARI_BASE_URL = "https://ops.zhouhaoze.top"; interface KomariNode { + uuid: string; name: string; region: string; // flag emoji per Komari's public API } @@ -11,6 +12,22 @@ interface KomariNodesResponse { data: KomariNode[]; } +export interface NodeLocation { + uuid: string; + name: string; + lat: number; + lng: number; + city: string; + country: string; + countryCode: string; +} + +interface NodeLocationsFile { + version: number; + nodes: KomariNode[]; + locations: NodeLocation[]; +} + export interface RegionBreakdown { flag: string; count: number; @@ -21,24 +38,61 @@ export interface InfraSnapshot { regionCount: number; regions: string[]; regionBreakdown: RegionBreakdown[]; + nodes: KomariNode[]; + nodeLocations: NodeLocation[]; live: boolean; } +interface NodeMapData { + nodes: KomariNode[]; + locations: NodeLocation[]; +} + +async function fetchNodeMap(): Promise { + try { + const response = await fetch("/node-locations.json", { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return { nodes: [], locations: [] }; + const body: NodeLocationsFile = await response.json(); + if (body.version !== 1 || !Array.isArray(body.nodes) || !Array.isArray(body.locations)) { + return { nodes: [], locations: [] }; + } + const nodes = body.nodes.filter( + (node) => typeof node.uuid === "string" && typeof node.name === "string" && typeof node.region === "string", + ); + const locations = body.locations.filter( + (location) => + typeof location.uuid === "string" && + Number.isFinite(location.lat) && + Number.isFinite(location.lng) && + Math.abs(location.lat) <= 90 && + Math.abs(location.lng) <= 180, + ); + return { nodes, locations }; + } catch { + return { nodes: [], locations: [] }; + } +} + export async function fetchInfraSnapshot(): Promise { + const nodeMapPromise = fetchNodeMap(); if (!KOMARI_BASE_URL) { + const nodeMap = await nodeMapPromise; return { serverCount: infraFallback.serverCount, regionCount: infraFallback.regionCount, regions: [], regionBreakdown: [], + nodes: nodeMap.nodes, + nodeLocations: nodeMap.locations, live: false, }; } try { - const res = await fetch(`${KOMARI_BASE_URL}/api/nodes`, { - signal: AbortSignal.timeout(5000), - }); + const [res, nodeMap] = await Promise.all([ + fetch(`${KOMARI_BASE_URL}/api/nodes`, { signal: AbortSignal.timeout(5000) }), + nodeMapPromise, + ]); if (!res.ok) throw new Error(`Komari responded ${res.status}`); const body: KomariNodesResponse = await res.json(); const nodes = body.data; @@ -56,14 +110,19 @@ export async function fetchInfraSnapshot(): Promise { regionCount: regions.length || infraFallback.regionCount, regions, regionBreakdown, + nodes, + nodeLocations: nodeMap.locations, live: true, }; } catch { + const nodeMap = await nodeMapPromise; return { - serverCount: infraFallback.serverCount, + serverCount: nodeMap.nodes.length || infraFallback.serverCount, regionCount: infraFallback.regionCount, regions: [], regionBreakdown: [], + nodes: nodeMap.nodes, + nodeLocations: nodeMap.locations, live: false, }; } diff --git a/src/locales/en.json b/src/locales/en.json index 7d7d350..eadf79a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -7,11 +7,11 @@ "nav.langToggle": "Switch language", "hero.eyebrow": "Student / Developer", - "hero.name": "Haoze Zhou", + "hero.name": "ZaneChow", "hero.tagline": "CS undergraduate at Universiti Malaya, building and running a few of my own services on the side.", "hero.cta": "View projects", "hero.more": "About me", - "hero.note": "Currently running 38 servers across 19 regions, live-monitored via Komari.", + "hero.note": "Currently maintaining multiple servers across several regions, live-monitored via Komari.", "hero.stat.servers": "Servers", "hero.stat.regions": "Regions", "hero.stat.projects": "Projects", @@ -61,5 +61,5 @@ "contact.github": "GitHub", "contact.telegram": "Telegram", - "footer.text": "© 2026 Haoze Zhou" + "footer.text": "© 2026 ZaneChow" } diff --git a/src/locales/zh.json b/src/locales/zh.json index e93bb11..d50f3ca 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -11,7 +11,7 @@ "hero.tagline": "马来亚大学计算机科学在读,业余搭建和维护一些自己的服务与工具。", "hero.cta": "查看项目", "hero.more": "关于我", - "hero.note": "目前维护着 38 台服务器,分布在 19 个地区,由 Komari 实时监控。", + "hero.note": "目前维护着多台服务器,分布在多个地区,由 Komari 实时监控。", "hero.stat.servers": "服务器", "hero.stat.regions": "地区", "hero.stat.projects": "项目", diff --git a/src/main.ts b/src/main.ts index 238ef99..4033074 100644 --- a/src/main.ts +++ b/src/main.ts @@ -260,7 +260,7 @@ function setupInfraGlobe(): void { async function mountGlobeWhenReady(container: HTMLElement, canvasMount: HTMLElement): Promise { const snapshot = await infraSnapshotPromise; - if (!snapshot.regionBreakdown.length) return; + if (!snapshot.regionBreakdown.length && !snapshot.nodes.length && !snapshot.nodeLocations.length) return; const { mountGlobe } = await import("./globe"); container.classList.add("has-live"); mountGlobe(canvasMount, snapshot); diff --git a/src/seo.ts b/src/seo.ts index 7839a68..a3fa4b4 100644 --- a/src/seo.ts +++ b/src/seo.ts @@ -1,10 +1,11 @@ import type { Lang } from "./i18n"; +import { IS_CN_SITE, SITE_ORIGIN, SITE_URL } from "./site-config"; const SEO = { en: { - title: "Haoze Zhou | Computer Science Student & Developer", - description: "Haoze Zhou is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects.", - imageAlt: "Haoze Zhou personal website", + title: "ZaneChow | Computer Science Student & Developer", + description: "ZaneChow is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects.", + imageAlt: "ZaneChow personal website", }, zh: { title: "周昊泽 | 计算机科学学生与开发者", @@ -17,7 +18,7 @@ const EN_URL = "https://www.zhouhaoze.com/"; const ZH_URL = "https://www.zhouhaoze.cn/"; function currentUrl(): string { - return `${window.location.origin}${window.location.pathname}`; + return `${SITE_ORIGIN}${window.location.pathname}`; } function setMeta(selector: string, content: string): void { @@ -39,10 +40,16 @@ export function updateSeo(lang: Lang): void { setMeta('meta[property="og:title"]', copy.title); setMeta('meta[property="og:description"]', copy.description); setMeta('meta[property="og:url"]', canonical); + setMeta('meta[property="og:image"]', `${SITE_URL}favicon.png`); setMeta('meta[property="og:image:alt"]', copy.imageAlt); setMeta('meta[property="og:locale"]', lang === "zh" ? "zh_CN" : "en_US"); + setMeta('meta[property="og:locale:alternate"]', lang === "zh" ? "en_US" : "zh_CN"); setMeta('meta[name="twitter:title"]', copy.title); setMeta('meta[name="twitter:description"]', copy.description); + setMeta('meta[name="twitter:image"]', `${SITE_URL}favicon.png`); + + const sameAs = ["https://github.com/YIYI-16"]; + if (!IS_CN_SITE) sameAs.push("https://t.me/yiyitelegram_bot"); const structuredData = { "@context": "https://schema.org", @@ -50,11 +57,11 @@ export function updateSeo(lang: Lang): void { { "@type": "Person", "@id": `${canonical}#person`, - name: lang === "zh" ? "周昊泽" : "Haoze Zhou", - alternateName: lang === "zh" ? "Haoze Zhou" : "周昊泽", + name: lang === "zh" ? "周昊泽" : "ZaneChow", + alternateName: lang === "zh" ? "ZaneChow" : "周昊泽", url: canonical, email: "mailto:me@zhouhaoze.com", - sameAs: ["https://github.com/YIYI-16", "https://t.me/yiyitelegram_bot"], + sameAs, jobTitle: lang === "zh" ? "计算机科学本科生" : "Computer Science undergraduate", affiliation: { "@type": "CollegeOrUniversity", name: "Universiti Malaya" }, }, diff --git a/src/site-config.ts b/src/site-config.ts new file mode 100644 index 0000000..31ceb46 --- /dev/null +++ b/src/site-config.ts @@ -0,0 +1,6 @@ +export type SiteVariant = "com" | "cn"; + +export const SITE_VARIANT: SiteVariant = __SITE_VARIANT__; +export const IS_CN_SITE = SITE_VARIANT === "cn"; +export const SITE_ORIGIN = IS_CN_SITE ? "https://www.zhouhaoze.cn" : "https://www.zhouhaoze.com"; +export const SITE_URL = `${SITE_ORIGIN}/`; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..8af77db --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __SITE_VARIANT__: "com" | "cn"; diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..091f165 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,126 @@ +import { rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { defineConfig, loadEnv, type Plugin, type ResolvedConfig } from "vite"; +import { generateNodeLocations, type NodeLocationBuildOptions } from "./build/node-locations.ts"; + +type SiteVariant = "com" | "cn"; + +const SITE_CONFIG = { + com: { + origin: "https://www.zhouhaoze.com", + lang: "en", + locale: "en_US", + alternateLocale: "zh_CN", + title: "ZaneChow | Computer Science Student & Developer", + description: + "ZaneChow is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects.", + imageAlt: "ZaneChow personal website", + }, + cn: { + origin: "https://www.zhouhaoze.cn", + lang: "zh-CN", + locale: "zh_CN", + alternateLocale: "en_US", + title: "周昊泽 | 计算机科学学生与开发者", + description: "周昊泽是马来亚大学计算机科学本科生,专注于软件开发、自建基础设施和开源项目。", + imageAlt: "周昊泽个人网站", + }, +} as const; + +function resolveVariant(value: string | undefined): SiteVariant { + const variant = value?.toLowerCase() || "com"; + if (variant !== "com" && variant !== "cn") { + throw new Error(`SITE_VARIANT must be "com" or "cn"; received "${value}".`); + } + return variant; +} + +function siteAssetsPlugin(variant: SiteVariant, nodeLocationOptions: NodeLocationBuildOptions): Plugin { + const site = SITE_CONFIG[variant]; + const siteUrl = `${site.origin}/`; + let resolvedConfig: ResolvedConfig; + + const robots = `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}sitemap.xml\n`; + const sitemap = ` + + + ${siteUrl} + 2026-08-20 + + +`; + + return { + name: "site-variant-assets", + configResolved(config) { + resolvedConfig = config; + }, + transformIndexHtml(html) { + const replacements: Record = { + SITE_LANG: site.lang, + SITE_TITLE: site.title, + SITE_DESCRIPTION: site.description, + SITE_URL: siteUrl, + SITE_LOCALE: site.locale, + SITE_ALTERNATE_LOCALE: site.alternateLocale, + SITE_IMAGE_ALT: site.imageAlt, + }; + + return Object.entries(replacements).reduce( + (result, [key, value]) => result.replaceAll(`%${key}%`, value), + html, + ); + }, + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url === "/robots.txt") { + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.end(robots); + return; + } + if (request.url === "/sitemap.xml") { + response.setHeader("Content-Type", "application/xml; charset=utf-8"); + response.end(sitemap); + return; + } + next(); + }); + }, + async closeBundle() { + const outDir = resolve(resolvedConfig.root, resolvedConfig.build.outDir); + let nodeLocations; + try { + nodeLocations = await generateNodeLocations(nodeLocationOptions); + console.info( + `[node-locations] Resolved ${nodeLocations.locations.length}/${nodeLocations.nodes.length} precise node locations.`, + ); + } catch (error) { + console.warn(`[node-locations] ${error instanceof Error ? error.message : String(error)}`); + nodeLocations = { version: 1, generatedAt: null, nodes: [], locations: [] } as const; + } + await Promise.all([ + writeFile(resolve(outDir, "robots.txt"), robots, "utf8"), + writeFile(resolve(outDir, "sitemap.xml"), sitemap, "utf8"), + rm(resolve(outDir, "sitemap-cn.xml"), { force: true }), + writeFile(resolve(outDir, "node-locations.json"), `${JSON.stringify(nodeLocations)}\n`, "utf8"), + ]); + }, + }; +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const variant = resolveVariant(process.env.SITE_VARIANT ?? env.SITE_VARIANT); + const nodeLocationOptions = { + komariBaseUrl: process.env.KOMARI_BASE_URL ?? env.KOMARI_BASE_URL ?? "https://ops.zhouhaoze.top", + komariApiKey: process.env.KOMARI_API_KEY ?? env.KOMARI_API_KEY, + ipinfoToken: process.env.IPINFO_TOKEN ?? env.IPINFO_TOKEN, + }; + + return { + define: { + __SITE_VARIANT__: JSON.stringify(variant), + }, + plugins: [siteAssetsPlugin(variant, nodeLocationOptions)], + }; +});