feat: implement site variant support and enhance SEO with dynamic meta tags

This commit is contained in:
2026-08-20 18:51:50 +08:00
parent d892c9751b
commit 4f702493cf
18 changed files with 506 additions and 57 deletions
+8
View File
@@ -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=
+215
View File
@@ -0,0 +1,215 @@
import { isIP } from "node:net";
interface KomariRpcResponse {
result?: Record<string, KomariNode>;
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<Record<string, unknown>> {
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<Record<string, unknown>>;
}
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<GeoResult | null> {
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<GeoResult | null> {
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<GeoResult | null> {
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<GeoResult | null> {
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<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
async function run(): Promise<void> {
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<KomariNode[]> {
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<PublicNode[]> {
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<NodeLocationsFile> {
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,
};
}
+18 -18
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="%SITE_LANG%">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
@@ -7,33 +7,33 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f4f1eb" /> <meta name="theme-color" content="#f4f1eb" />
<title>Haoze Zhou | Computer Science Student &amp; Developer</title> <title>%SITE_TITLE%</title>
<meta name="description" content="Haoze Zhou is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects." /> <meta name="description" content="%SITE_DESCRIPTION%" />
<meta name="author" content="Haoze Zhou" /> <meta name="author" content="ZaneChow" />
<meta name="robots" content="index, follow, max-image-preview:large" /> <meta name="robots" content="index, follow, max-image-preview:large" />
<link rel="canonical" href="https://www.zhouhaoze.com/" /> <link rel="canonical" href="%SITE_URL%" />
<link rel="alternate" hreflang="en" href="https://www.zhouhaoze.com/" /> <link rel="alternate" hreflang="en" href="https://www.zhouhaoze.com/" />
<link rel="alternate" hreflang="zh-CN" href="https://www.zhouhaoze.cn/" /> <link rel="alternate" hreflang="zh-CN" href="https://www.zhouhaoze.cn/" />
<link rel="alternate" hreflang="x-default" href="https://www.zhouhaoze.com/" /> <link rel="alternate" hreflang="x-default" href="https://www.zhouhaoze.com/" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Haoze Zhou" /> <meta property="og:site_name" content="ZaneChow" />
<meta property="og:title" content="Haoze Zhou | Computer Science Student &amp; Developer" /> <meta property="og:title" content="%SITE_TITLE%" />
<meta property="og:description" content="Software projects and self-hosted infrastructure by Haoze Zhou." /> <meta property="og:description" content="%SITE_DESCRIPTION%" />
<meta property="og:url" content="https://www.zhouhaoze.com/" /> <meta property="og:url" content="%SITE_URL%" />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="%SITE_LOCALE%" />
<meta property="og:locale:alternate" content="zh_CN" /> <meta property="og:locale:alternate" content="%SITE_ALTERNATE_LOCALE%" />
<meta property="og:image" content="https://www.zhouhaoze.com/favicon.png" /> <meta property="og:image" content="%SITE_URL%favicon.png" />
<meta property="og:image:alt" content="Haoze Zhou personal website" /> <meta property="og:image:alt" content="%SITE_IMAGE_ALT%" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Haoze Zhou | Computer Science Student &amp; Developer" /> <meta name="twitter:title" content="%SITE_TITLE%" />
<meta name="twitter:description" content="Software projects and self-hosted infrastructure by Haoze Zhou." /> <meta name="twitter:description" content="%SITE_DESCRIPTION%" />
<meta name="twitter:image" content="https://www.zhouhaoze.com/favicon.png" /> <meta name="twitter:image" content="%SITE_URL%favicon.png" />
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<noscript> <noscript>
<main> <main>
<h1>Haoze Zhou</h1> <h1>ZaneChow</h1>
<p>Computer Science undergraduate at Universiti Malaya building software, open-source projects, and self-hosted infrastructure.</p> <p>Computer Science undergraduate at Universiti Malaya building software, open-source projects, and self-hosted infrastructure.</p>
<nav aria-label="Primary navigation"> <nav aria-label="Primary navigation">
<a href="#about">About</a> <a href="#about">About</a>
@@ -42,7 +42,7 @@
</nav> </nav>
<section id="about"><h2>About</h2><p>I study Computer Science at Universiti Malaya and learn by building practical systems.</p></section> <section id="about"><h2>About</h2><p>I study Computer Science at Universiti Malaya and learn by building practical systems.</p></section>
<section id="projects"><h2>Projects</h2><p>Explore my open-source work, including AuthRouter, a unified identity-provider login gateway.</p></section> <section id="projects"><h2>Projects</h2><p>Explore my open-source work, including AuthRouter, a unified identity-provider login gateway.</p></section>
<section id="contact"><h2>Contact</h2><p><a href="mailto:me@zhouhaoze.com">Email Haoze Zhou</a> · <a href="https://github.com/YIYI-16">GitHub</a></p></section> <section id="contact"><h2>Contact</h2><p><a href="mailto:me@zhouhaoze.com">Email ZaneChow</a> · <a href="https://github.com/YIYI-16">GitHub</a></p></section>
</main> </main>
</noscript> </noscript>
</div> </div>
@@ -1 +0,0 @@
538b0c38e87859dfdd9621f3f5f3d26b
-5
View File
@@ -1,5 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://www.zhouhaoze.com/sitemap.xml
Sitemap: https://www.zhouhaoze.cn/sitemap-cn.xml
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.zhouhaoze.cn/</loc>
<lastmod>2026-08-13</lastmod>
</url>
</urlset>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.zhouhaoze.com/</loc>
<lastmod>2026-08-13</lastmod>
</url>
</urlset>
+5 -1
View File
@@ -1,3 +1,5 @@
import { IS_CN_SITE } from "../site-config";
export interface Contact { export interface Contact {
icon: string; icon: string;
labelKey: string; labelKey: string;
@@ -8,5 +10,7 @@ export interface Contact {
export const contacts: Contact[] = [ export const contacts: Contact[] = [
{ icon: "ph-envelope-simple", labelKey: "contact.email", href: "mailto:me@zhouhaoze.com" }, { 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-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 }]
: []),
]; ];
+41 -1
View File
@@ -16,12 +16,47 @@ interface GlobePoint {
} }
function buildPoints(snapshot: InfraSnapshot): 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 }) => { return snapshot.regionBreakdown.flatMap(({ flag, count }) => {
const info = flagToRegionInfo(flag); const info = flagToRegionInfo(flag);
return info ? [{ lat: info.lat, lng: info.lng, name: info.name, count }] : []; return info ? [{ lat: info.lat, lng: info.lng, name: info.name, count }] : [];
}); });
} }
function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] {
const groups = new Map<string, GlobePoint[]>();
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 { function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback; return value || fallback;
@@ -61,6 +96,8 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
container.replaceChildren(renderer.domElement); container.replaceChildren(renderer.domElement);
renderer.domElement.style.cursor = "grab"; renderer.domElement.style.cursor = "grab";
renderer.domElement.setAttribute("aria-label", "Interactive globe showing server regions"); 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)); scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const frontLight = new THREE.DirectionalLight(0xffffff, 0.65); const frontLight = new THREE.DirectionalLight(0xffffff, 0.65);
@@ -97,7 +134,10 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
.labelsData(points) .labelsData(points)
.labelLat("lat") .labelLat("lat")
.labelLng("lng") .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) .labelSize(1.15)
.labelColor(() => textColor) .labelColor(() => textColor)
.labelDotRadius(0.32) .labelDotRadius(0.32)
+2 -1
View File
@@ -1,6 +1,7 @@
import en from "./locales/en.json"; import en from "./locales/en.json";
import zh from "./locales/zh.json"; import zh from "./locales/zh.json";
import { updateSeo } from "./seo"; import { updateSeo } from "./seo";
import { IS_CN_SITE } from "./site-config";
export type Lang = "en" | "zh"; export type Lang = "en" | "zh";
@@ -9,7 +10,7 @@ const dictionaries: Record<Lang, Record<string, string>> = { en, zh };
const STORAGE_KEY = "pw-lang"; const STORAGE_KEY = "pw-lang";
function detectLang(): Lang { function detectLang(): Lang {
if (window.location.hostname.endsWith(".cn")) return "zh"; if (IS_CN_SITE) return "zh";
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "zh") return stored; if (stored === "en" || stored === "zh") return stored;
return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en"; return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
+63 -4
View File
@@ -3,6 +3,7 @@ import { infraFallback } from "./data/infra";
const KOMARI_BASE_URL = "https://ops.zhouhaoze.top"; const KOMARI_BASE_URL = "https://ops.zhouhaoze.top";
interface KomariNode { interface KomariNode {
uuid: string;
name: string; name: string;
region: string; // flag emoji per Komari's public API region: string; // flag emoji per Komari's public API
} }
@@ -11,6 +12,22 @@ interface KomariNodesResponse {
data: KomariNode[]; 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 { export interface RegionBreakdown {
flag: string; flag: string;
count: number; count: number;
@@ -21,24 +38,61 @@ export interface InfraSnapshot {
regionCount: number; regionCount: number;
regions: string[]; regions: string[];
regionBreakdown: RegionBreakdown[]; regionBreakdown: RegionBreakdown[];
nodes: KomariNode[];
nodeLocations: NodeLocation[];
live: boolean; live: boolean;
} }
interface NodeMapData {
nodes: KomariNode[];
locations: NodeLocation[];
}
async function fetchNodeMap(): Promise<NodeMapData> {
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<InfraSnapshot> { export async function fetchInfraSnapshot(): Promise<InfraSnapshot> {
const nodeMapPromise = fetchNodeMap();
if (!KOMARI_BASE_URL) { if (!KOMARI_BASE_URL) {
const nodeMap = await nodeMapPromise;
return { return {
serverCount: infraFallback.serverCount, serverCount: infraFallback.serverCount,
regionCount: infraFallback.regionCount, regionCount: infraFallback.regionCount,
regions: [], regions: [],
regionBreakdown: [], regionBreakdown: [],
nodes: nodeMap.nodes,
nodeLocations: nodeMap.locations,
live: false, live: false,
}; };
} }
try { try {
const res = await fetch(`${KOMARI_BASE_URL}/api/nodes`, { const [res, nodeMap] = await Promise.all([
signal: AbortSignal.timeout(5000), fetch(`${KOMARI_BASE_URL}/api/nodes`, { signal: AbortSignal.timeout(5000) }),
}); nodeMapPromise,
]);
if (!res.ok) throw new Error(`Komari responded ${res.status}`); if (!res.ok) throw new Error(`Komari responded ${res.status}`);
const body: KomariNodesResponse = await res.json(); const body: KomariNodesResponse = await res.json();
const nodes = body.data; const nodes = body.data;
@@ -56,14 +110,19 @@ export async function fetchInfraSnapshot(): Promise<InfraSnapshot> {
regionCount: regions.length || infraFallback.regionCount, regionCount: regions.length || infraFallback.regionCount,
regions, regions,
regionBreakdown, regionBreakdown,
nodes,
nodeLocations: nodeMap.locations,
live: true, live: true,
}; };
} catch { } catch {
const nodeMap = await nodeMapPromise;
return { return {
serverCount: infraFallback.serverCount, serverCount: nodeMap.nodes.length || infraFallback.serverCount,
regionCount: infraFallback.regionCount, regionCount: infraFallback.regionCount,
regions: [], regions: [],
regionBreakdown: [], regionBreakdown: [],
nodes: nodeMap.nodes,
nodeLocations: nodeMap.locations,
live: false, live: false,
}; };
} }
+3 -3
View File
@@ -7,11 +7,11 @@
"nav.langToggle": "Switch language", "nav.langToggle": "Switch language",
"hero.eyebrow": "Student / Developer", "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.tagline": "CS undergraduate at Universiti Malaya, building and running a few of my own services on the side.",
"hero.cta": "View projects", "hero.cta": "View projects",
"hero.more": "About me", "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.servers": "Servers",
"hero.stat.regions": "Regions", "hero.stat.regions": "Regions",
"hero.stat.projects": "Projects", "hero.stat.projects": "Projects",
@@ -61,5 +61,5 @@
"contact.github": "GitHub", "contact.github": "GitHub",
"contact.telegram": "Telegram", "contact.telegram": "Telegram",
"footer.text": "© 2026 Haoze Zhou" "footer.text": "© 2026 ZaneChow"
} }
+1 -1
View File
@@ -11,7 +11,7 @@
"hero.tagline": "马来亚大学计算机科学在读,业余搭建和维护一些自己的服务与工具。", "hero.tagline": "马来亚大学计算机科学在读,业余搭建和维护一些自己的服务与工具。",
"hero.cta": "查看项目", "hero.cta": "查看项目",
"hero.more": "关于我", "hero.more": "关于我",
"hero.note": "目前维护着 38 台服务器,分布在 19 个地区,由 Komari 实时监控。", "hero.note": "目前维护着多台服务器,分布在多个地区,由 Komari 实时监控。",
"hero.stat.servers": "服务器", "hero.stat.servers": "服务器",
"hero.stat.regions": "地区", "hero.stat.regions": "地区",
"hero.stat.projects": "项目", "hero.stat.projects": "项目",
+1 -1
View File
@@ -260,7 +260,7 @@ function setupInfraGlobe(): void {
async function mountGlobeWhenReady(container: HTMLElement, canvasMount: HTMLElement): Promise<void> { async function mountGlobeWhenReady(container: HTMLElement, canvasMount: HTMLElement): Promise<void> {
const snapshot = await infraSnapshotPromise; 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"); const { mountGlobe } = await import("./globe");
container.classList.add("has-live"); container.classList.add("has-live");
mountGlobe(canvasMount, snapshot); mountGlobe(canvasMount, snapshot);
+14 -7
View File
@@ -1,10 +1,11 @@
import type { Lang } from "./i18n"; import type { Lang } from "./i18n";
import { IS_CN_SITE, SITE_ORIGIN, SITE_URL } from "./site-config";
const SEO = { const SEO = {
en: { en: {
title: "Haoze Zhou | Computer Science Student & Developer", title: "ZaneChow | Computer Science Student & Developer",
description: "Haoze Zhou is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects.", description: "ZaneChow is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects.",
imageAlt: "Haoze Zhou personal website", imageAlt: "ZaneChow personal website",
}, },
zh: { zh: {
title: "周昊泽 | 计算机科学学生与开发者", title: "周昊泽 | 计算机科学学生与开发者",
@@ -17,7 +18,7 @@ const EN_URL = "https://www.zhouhaoze.com/";
const ZH_URL = "https://www.zhouhaoze.cn/"; const ZH_URL = "https://www.zhouhaoze.cn/";
function currentUrl(): string { function currentUrl(): string {
return `${window.location.origin}${window.location.pathname}`; return `${SITE_ORIGIN}${window.location.pathname}`;
} }
function setMeta(selector: string, content: string): void { 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:title"]', copy.title);
setMeta('meta[property="og:description"]', copy.description); setMeta('meta[property="og:description"]', copy.description);
setMeta('meta[property="og:url"]', canonical); 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:image:alt"]', copy.imageAlt);
setMeta('meta[property="og:locale"]', lang === "zh" ? "zh_CN" : "en_US"); 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:title"]', copy.title);
setMeta('meta[name="twitter:description"]', copy.description); 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 = { const structuredData = {
"@context": "https://schema.org", "@context": "https://schema.org",
@@ -50,11 +57,11 @@ export function updateSeo(lang: Lang): void {
{ {
"@type": "Person", "@type": "Person",
"@id": `${canonical}#person`, "@id": `${canonical}#person`,
name: lang === "zh" ? "周昊泽" : "Haoze Zhou", name: lang === "zh" ? "周昊泽" : "ZaneChow",
alternateName: lang === "zh" ? "Haoze Zhou" : "周昊泽", alternateName: lang === "zh" ? "ZaneChow" : "周昊泽",
url: canonical, url: canonical,
email: "mailto:me@zhouhaoze.com", email: "mailto:me@zhouhaoze.com",
sameAs: ["https://github.com/YIYI-16", "https://t.me/yiyitelegram_bot"], sameAs,
jobTitle: lang === "zh" ? "计算机科学本科生" : "Computer Science undergraduate", jobTitle: lang === "zh" ? "计算机科学本科生" : "Computer Science undergraduate",
affiliation: { "@type": "CollegeOrUniversity", name: "Universiti Malaya" }, affiliation: { "@type": "CollegeOrUniversity", name: "Universiti Malaya" },
}, },
+6
View File
@@ -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}/`;
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __SITE_VARIANT__: "com" | "cn";
+126
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${siteUrl}</loc>
<lastmod>2026-08-20</lastmod>
</url>
</urlset>
`;
return {
name: "site-variant-assets",
configResolved(config) {
resolvedConfig = config;
},
transformIndexHtml(html) {
const replacements: Record<string, string> = {
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)],
};
});