feat: enhance SEO with meta tags and sitemap, add DocumentMeta component
This commit is contained in:
34
index.html
34
index.html
@@ -1,9 +1,39 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-Hant">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ARK 資料庫</title>
|
<meta name="theme-color" content="#08070c" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<meta name="application-name" content="ARK 资料库" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
|
||||||
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="ARK 资料库" />
|
||||||
|
<meta property="og:title" content="ARK 官方数据库" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="/assets/ark-mark.png" />
|
||||||
|
<meta property="og:locale" content="zh_CN" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="ARK 官方数据库" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="/assets/ark-mark.png" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="/assets/ark-library/header-logo.svg"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="/assets/ark-mark.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<title>ARK 官方数据库</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-ark-bg text-neutral-100">
|
<body class="bg-ark-bg text-neutral-100">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://ark-library.com/sitemap.xml
|
||||||
17
public/site.webmanifest
Normal file
17
public/site.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "ARK 资料库",
|
||||||
|
"short_name": "ARK Library",
|
||||||
|
"description": "ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#08070c",
|
||||||
|
"theme_color": "#08070c",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/ark-mark.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
public/sitemap.xml
Normal file
18
public/sitemap.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://ark-library.com/</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ark-library.com/browse</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ark-library.com/categories</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ark-library.com/official-recommendations</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ark-library.com/about</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
194
src/components/DocumentMeta.tsx
Normal file
194
src/components/DocumentMeta.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useI18n, type Lang } from "../i18n";
|
||||||
|
|
||||||
|
type PageMeta = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptions: Record<Lang, Record<string, string>> = {
|
||||||
|
"zh-CN": {
|
||||||
|
home: "ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。",
|
||||||
|
browse: "浏览 ARK 资料库中的官方资料、教程、公告、图片、视频与可下载文件。",
|
||||||
|
categories:
|
||||||
|
"按分类探索 ARK 官方资料,快速定位所需教材、公告、视频、图片与文件。",
|
||||||
|
official: "查看 ARK 官方推荐资料,获取优先整理的重点内容与可信资源。",
|
||||||
|
favorites: "收藏功能开发中,未来可在这里集中管理常用 ARK 资料。",
|
||||||
|
about: "了解 ARK 资料库的用途、资料范围与本站索引说明。",
|
||||||
|
search: "在 ARK 资料库中搜索标题、分类、标签、简介、文件类型与正文内容。",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
home: "ARK Library organizes official decks, announcements, videos, images, and files so the community can find trusted resources quickly.",
|
||||||
|
browse:
|
||||||
|
"Browse official ARK resources, guides, announcements, images, videos, and downloadable files.",
|
||||||
|
categories:
|
||||||
|
"Explore ARK resources by category and quickly find guides, announcements, videos, images, and files.",
|
||||||
|
official:
|
||||||
|
"View official ARK recommendations and prioritized trusted resources.",
|
||||||
|
favorites:
|
||||||
|
"Favorites are in development and will help you manage commonly used ARK resources.",
|
||||||
|
about:
|
||||||
|
"Learn about the ARK Library purpose, resource scope, and indexing notes.",
|
||||||
|
search:
|
||||||
|
"Search ARK Library titles, categories, tags, summaries, file types, and body text.",
|
||||||
|
},
|
||||||
|
ja: {},
|
||||||
|
ko: {},
|
||||||
|
vi: {},
|
||||||
|
id: {},
|
||||||
|
ms: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const localeMap: Record<Lang, string> = {
|
||||||
|
"zh-CN": "zh_CN",
|
||||||
|
en: "en_US",
|
||||||
|
ja: "ja_JP",
|
||||||
|
ko: "ko_KR",
|
||||||
|
vi: "vi_VN",
|
||||||
|
id: "id_ID",
|
||||||
|
ms: "ms_MY",
|
||||||
|
};
|
||||||
|
|
||||||
|
function metaDescription(lang: Lang, key: string) {
|
||||||
|
return (
|
||||||
|
descriptions[lang][key] || descriptions.en[key] || descriptions.en.home
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeta(selector: string, attr: "name" | "property", value: string) {
|
||||||
|
let meta = document.head.querySelector<HTMLMetaElement>(selector);
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement("meta");
|
||||||
|
meta.setAttribute(attr, value);
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLink(rel: string, href: string) {
|
||||||
|
let link = document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.rel = rel;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.href = href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeMeta(
|
||||||
|
pathname: string,
|
||||||
|
search: string,
|
||||||
|
lang: Lang,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): PageMeta {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const q = params.get("q")?.trim();
|
||||||
|
const sort = params.get("sort");
|
||||||
|
|
||||||
|
if (pathname === "/") {
|
||||||
|
return {
|
||||||
|
title: t("heroTitle"),
|
||||||
|
description: metaDescription(lang, "home"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/browse") {
|
||||||
|
if (q) {
|
||||||
|
return {
|
||||||
|
title: `${t("search")}: ${q}`,
|
||||||
|
description: metaDescription(lang, "search"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sort === "latest") {
|
||||||
|
return {
|
||||||
|
title: t("latest"),
|
||||||
|
description: metaDescription(lang, "browse"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sort === "popular") {
|
||||||
|
return {
|
||||||
|
title: t("popular"),
|
||||||
|
description: metaDescription(lang, "browse"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { title: t("all"), description: metaDescription(lang, "browse") };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/categories") {
|
||||||
|
return {
|
||||||
|
title: t("categories"),
|
||||||
|
description: metaDescription(lang, "categories"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/official-recommendations") {
|
||||||
|
return {
|
||||||
|
title: t("official"),
|
||||||
|
description: metaDescription(lang, "official"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/favorites") {
|
||||||
|
return {
|
||||||
|
title: t("favorites"),
|
||||||
|
description: metaDescription(lang, "favorites"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/about") {
|
||||||
|
return {
|
||||||
|
title: t("footerAbout"),
|
||||||
|
description: metaDescription(lang, "about"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title: t("brand"), description: metaDescription(lang, "home") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentMeta() {
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
const { lang, t } = useI18n();
|
||||||
|
const meta = useMemo(
|
||||||
|
() => routeMeta(pathname, search, lang, t),
|
||||||
|
[lang, pathname, search, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const brand = t("brand");
|
||||||
|
const title = pathname === "/" ? meta.title : `${meta.title} | ${brand}`;
|
||||||
|
const canonical = `${window.location.origin}${pathname}${search}`;
|
||||||
|
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.title = title;
|
||||||
|
|
||||||
|
setMeta('meta[name="description"]', "name", "description").content =
|
||||||
|
meta.description;
|
||||||
|
setMeta(
|
||||||
|
'meta[name="application-name"]',
|
||||||
|
"name",
|
||||||
|
"application-name",
|
||||||
|
).content = brand;
|
||||||
|
setMeta('meta[property="og:title"]', "property", "og:title").content =
|
||||||
|
title;
|
||||||
|
setMeta(
|
||||||
|
'meta[property="og:description"]',
|
||||||
|
"property",
|
||||||
|
"og:description",
|
||||||
|
).content = meta.description;
|
||||||
|
setMeta('meta[property="og:url"]', "property", "og:url").content =
|
||||||
|
canonical;
|
||||||
|
setMeta('meta[property="og:locale"]', "property", "og:locale").content =
|
||||||
|
localeMap[lang];
|
||||||
|
setMeta('meta[name="twitter:title"]', "name", "twitter:title").content =
|
||||||
|
title;
|
||||||
|
setMeta(
|
||||||
|
'meta[name="twitter:description"]',
|
||||||
|
"name",
|
||||||
|
"twitter:description",
|
||||||
|
).content = meta.description;
|
||||||
|
setLink("canonical", canonical);
|
||||||
|
}, [lang, meta.description, meta.title, pathname, search, t]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||||
|
import { DocumentMeta } from "../components/DocumentMeta";
|
||||||
import { SearchPanel } from "../components/SearchPanel";
|
import { SearchPanel } from "../components/SearchPanel";
|
||||||
import { useI18n, type Lang } from "../i18n";
|
import { useI18n, type Lang } from "../i18n";
|
||||||
import { LANG_OPTIONS } from "../i18nLanguages";
|
import { LANG_OPTIONS } from "../i18nLanguages";
|
||||||
@@ -274,6 +275,9 @@ export function PublicLayout() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mobileMenuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const desktopMenuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
|
||||||
const na = (which: PublicNavWhich) =>
|
const na = (which: PublicNavWhich) =>
|
||||||
@@ -290,8 +294,35 @@ export function PublicLayout() {
|
|||||||
setMobileSearchOpen(false);
|
setMobileSearchOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const closeOnOutside = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (
|
||||||
|
menuRef.current?.contains(target) ||
|
||||||
|
mobileMenuButtonRef.current?.contains(target) ||
|
||||||
|
desktopMenuButtonRef.current?.contains(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const closeOnScroll = () => setOpen(false);
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", closeOnOutside);
|
||||||
|
document.addEventListener("touchstart", closeOnOutside);
|
||||||
|
window.addEventListener("scroll", closeOnScroll, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", closeOnOutside);
|
||||||
|
document.removeEventListener("touchstart", closeOnOutside);
|
||||||
|
window.removeEventListener("scroll", closeOnScroll, true);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full flex flex-col">
|
<div className="min-h-full flex flex-col">
|
||||||
|
<DocumentMeta />
|
||||||
<header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
|
<header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
|
||||||
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
||||||
<Link
|
<Link
|
||||||
@@ -334,6 +365,7 @@ export function PublicLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
ref={mobileMenuButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-full bg-[#191921] text-[#a8a9ae] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
className="inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-full bg-[#191921] text-[#a8a9ae] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -453,6 +485,7 @@ export function PublicLayout() {
|
|||||||
className="hidden h-10 w-36 md:block lg:w-40"
|
className="hidden h-10 w-36 md:block lg:w-40"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
ref={desktopMenuButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@@ -466,7 +499,8 @@ export function PublicLayout() {
|
|||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div
|
<div
|
||||||
className={`${dropdownAnimationClass} grid gap-2 border-t border-ark-line bg-ark-nav px-4 py-3 min-[440px]:px-5 sm:px-6 md:px-9 min-[1200px]:hidden`}
|
ref={menuRef}
|
||||||
|
className={`${dropdownAnimationClass} fixed inset-x-0 top-[64px] z-50 grid gap-2 border-y border-ark-line bg-ark-nav px-4 py-3 shadow-2xl shadow-black/50 min-[440px]:px-5 sm:px-6 md:top-[70px] md:px-9 min-[1200px]:hidden`}
|
||||||
>
|
>
|
||||||
<div className="mb-1 hidden items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 md:flex">
|
<div className="mb-1 hidden items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 md:flex">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user