feat: enhance SEO and localization support with meta tags and structured data
This commit is contained in:
+4
-2
@@ -1,5 +1,6 @@
|
||||
import en from "./locales/en.json";
|
||||
import zh from "./locales/zh.json";
|
||||
import { updateSeo } from "./seo";
|
||||
|
||||
export type Lang = "en" | "zh";
|
||||
|
||||
@@ -8,6 +9,7 @@ const dictionaries: Record<Lang, Record<string, string>> = { en, zh };
|
||||
const STORAGE_KEY = "pw-lang";
|
||||
|
||||
function detectLang(): Lang {
|
||||
if (window.location.hostname.endsWith(".cn")) return "zh";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||
@@ -27,7 +29,7 @@ export function t(key: string): string {
|
||||
export function setLang(lang: Lang): void {
|
||||
currentLang = lang;
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
||||
updateSeo(lang);
|
||||
applyTranslations();
|
||||
listeners.forEach((fn) => fn(lang));
|
||||
}
|
||||
@@ -53,6 +55,6 @@ export function applyTranslations(): void {
|
||||
}
|
||||
|
||||
export function initI18n(): void {
|
||||
document.documentElement.lang = currentLang === "zh" ? "zh-CN" : "en";
|
||||
updateSeo(currentLang);
|
||||
applyTranslations();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"projects.title": "Projects",
|
||||
"projects.lead": "Things I've written and maintained. Click a card for more detail.",
|
||||
"projects.authrouter.tag": "Featured",
|
||||
"authrouter.imageAlt": "AuthRouter identity provider gateway project screenshot",
|
||||
"projects.authrouter.title": "AuthRouter",
|
||||
"projects.authrouter.desc": "Consolidates multiple identity providers behind a single login endpoint, with identity mapping across providers.",
|
||||
"projects.authrouter.detail": "Systems that connect to several identity providers often force users to jump between different login entry points, and identity data ends up scattered across them. AuthRouter merges these into one endpoint: callers integrate once, and requests get routed to the right upstream provider with identities mapped into a consistent format.",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"projects.title": "项目",
|
||||
"projects.lead": "一些自己写的和维护的项目,点击卡片可以看更多细节。",
|
||||
"projects.authrouter.tag": "重点项目",
|
||||
"authrouter.imageAlt": "AuthRouter 身份提供方网关项目截图",
|
||||
"projects.authrouter.title": "AuthRouter",
|
||||
"projects.authrouter.desc": "把多个身份提供方(IdP)整合到一个统一的登录入口,只需要调用一个端点就能完成登录,并支持在不同身份提供方之间做身份映射。",
|
||||
"projects.authrouter.detail": "很多系统会同时接入好几个身份提供方,用户登录时经常要在不同入口之间跳转,身份信息也难以统一。AuthRouter 把这些入口整合成一个,调用方只需要对接一个端点,就能把登录请求路由到正确的上游,并把不同上游的身份信息映射成统一的格式。",
|
||||
|
||||
+2
-3
@@ -288,7 +288,7 @@ function renderProjects(): void {
|
||||
<article class="project-featured" data-project="${p.id}">
|
||||
${
|
||||
p.image
|
||||
? `<img class="project-featured__media" src="${p.image}" alt="" loading="lazy" />`
|
||||
? `<img class="project-featured__media" src="${p.image}" alt="${t(`${p.id}.imageAlt`)}" loading="lazy" />`
|
||||
: `<div class="project-featured__media" data-i18n="projects.media.placeholder"></div>`
|
||||
}
|
||||
<div>
|
||||
@@ -323,7 +323,7 @@ function renderProjects(): void {
|
||||
<h3 class="modal__title" data-i18n="${project.titleKey}"></h3>
|
||||
${
|
||||
project.image
|
||||
? `<img class="modal__media" src="${project.image}" alt="" loading="lazy" />`
|
||||
? `<img class="modal__media" src="${project.image}" alt="${t(`${project.id}.imageAlt`)}" loading="lazy" />`
|
||||
: `<div class="modal__media" data-i18n="projects.media.placeholder"></div>`
|
||||
}
|
||||
<p class="modal__desc" data-i18n="${project.detailKey ?? project.descKey}"></p>
|
||||
@@ -407,4 +407,3 @@ renderInfraStats();
|
||||
|
||||
// Ensure the empty i18n keys used as placeholders (e.g. tag on non-featured items) don't render "undefined"
|
||||
document.querySelectorAll<HTMLElement>('[data-i18n=""]').forEach((el) => el.remove());
|
||||
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import type { Lang } from "./i18n";
|
||||
|
||||
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",
|
||||
},
|
||||
zh: {
|
||||
title: "周昊泽 | 计算机科学学生与开发者",
|
||||
description: "周昊泽是马来亚大学计算机科学本科生,专注于软件开发、自建基础设施和开源项目。",
|
||||
imageAlt: "周昊泽个人网站",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EN_URL = "https://www.zhouhaoze.com/";
|
||||
const ZH_URL = "https://www.zhouhaoze.cn/";
|
||||
|
||||
function currentUrl(): string {
|
||||
return `${window.location.origin}${window.location.pathname}`;
|
||||
}
|
||||
|
||||
function setMeta(selector: string, content: string): void {
|
||||
const element = document.querySelector<HTMLMetaElement>(selector);
|
||||
if (element) element.content = content;
|
||||
}
|
||||
|
||||
export function updateSeo(lang: Lang): void {
|
||||
const copy = SEO[lang];
|
||||
const canonical = currentUrl();
|
||||
|
||||
document.title = copy.title;
|
||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
||||
document.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.setAttribute("href", canonical);
|
||||
document.querySelector<HTMLLinkElement>('link[rel="alternate"][hreflang="en"]')?.setAttribute("href", EN_URL);
|
||||
document.querySelector<HTMLLinkElement>('link[rel="alternate"][hreflang="zh-CN"]')?.setAttribute("href", ZH_URL);
|
||||
document.querySelector<HTMLLinkElement>('link[rel="alternate"][hreflang="x-default"]')?.setAttribute("href", EN_URL);
|
||||
setMeta('meta[name="description"]', copy.description);
|
||||
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:alt"]', copy.imageAlt);
|
||||
setMeta('meta[property="og:locale"]', lang === "zh" ? "zh_CN" : "en_US");
|
||||
setMeta('meta[name="twitter:title"]', copy.title);
|
||||
setMeta('meta[name="twitter:description"]', copy.description);
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Person",
|
||||
"@id": `${canonical}#person`,
|
||||
name: lang === "zh" ? "周昊泽" : "Haoze Zhou",
|
||||
alternateName: lang === "zh" ? "Haoze Zhou" : "周昊泽",
|
||||
url: canonical,
|
||||
email: "mailto:me@zhouhaoze.com",
|
||||
sameAs: ["https://github.com/YIYI-16", "https://t.me/yiyitelegram_bot"],
|
||||
jobTitle: lang === "zh" ? "计算机科学本科生" : "Computer Science undergraduate",
|
||||
affiliation: { "@type": "CollegeOrUniversity", name: "Universiti Malaya" },
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${canonical}#website`,
|
||||
url: canonical,
|
||||
name: copy.title,
|
||||
description: copy.description,
|
||||
inLanguage: lang === "zh" ? "zh-CN" : "en",
|
||||
author: { "@id": `${canonical}#person` },
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareSourceCode",
|
||||
name: "AuthRouter",
|
||||
description:
|
||||
lang === "zh"
|
||||
? "将多个身份提供方整合到统一登录入口的开源项目。"
|
||||
: "An open-source gateway that unifies multiple identity providers behind one login endpoint.",
|
||||
codeRepository: "https://github.com/YIYI-16/AuthRouter",
|
||||
programmingLanguage: "Go",
|
||||
author: { "@id": `${canonical}#person` },
|
||||
},
|
||||
],
|
||||
};
|
||||
let jsonLd = document.querySelector<HTMLScriptElement>('script[data-seo="jsonld"]');
|
||||
if (!jsonLd) {
|
||||
jsonLd = document.createElement("script");
|
||||
jsonLd.type = "application/ld+json";
|
||||
jsonLd.dataset.seo = "jsonld";
|
||||
document.head.appendChild(jsonLd);
|
||||
}
|
||||
jsonLd.textContent = JSON.stringify(structuredData);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user