feat(seo): implement dynamic metadata, sitemap, and robots.txt

Add dynamic meta tags for routes and entity detail pages
Generate sitemap.xml and robots.txt dynamically in Vite
Change default frontend port from 3000 to 20015
This commit is contained in:
2026-05-03 14:31:22 +08:00
parent 282481bbcc
commit 1dab650c2c
19 changed files with 572 additions and 51 deletions

View File

@@ -3,8 +3,9 @@ POSTGRES_USER=pokopia
POSTGRES_PASSWORD=pokopia POSTGRES_PASSWORD=pokopia
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
BACKEND_PORT=3001 BACKEND_PORT=3001
FRONTEND_ORIGIN=http://localhost:3000 FRONTEND_ORIGIN=http://localhost:20015
APP_ORIGIN=http://localhost:3000 APP_ORIGIN=http://localhost:20015
VITE_API_BASE_URL=http://localhost:3001 VITE_API_BASE_URL=http://localhost:3001
VITE_SITE_URL=https://pokopiawiki.tootaio.com
RESEND_API_KEY= RESEND_API_KEY=
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>" EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"

View File

@@ -656,6 +656,28 @@ API 暴露边界:
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。 - 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
- 权限不足时前端可以隐藏或禁用对应操作;后端必须返回本地化 403并且不得在 UI 暴露内部权限 key 作为普通用户提示。 - 权限不足时前端可以隐藏或禁用对应操作;后端必须返回本地化 403并且不得在 UI 暴露内部权限 key 作为普通用户提示。
## Technical SEO
- 前端发布基础 SEO 静态资源:
- `favicon.ico`
- 默认社交分享图
- 品牌 Logo 素材
- `VITE_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`
- 前端入口 `index.html` 提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon客户端路由切换后根据当前路由更新页面 metadata。
- 主要公开浏览入口可索引:
- `/pokemon`
- `/habitats`
- `/items`
- `/recipes`
- `/checklist`
- `/life`
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL因此暂不输出 `hreflang`
## API 概览 ## API 概览
公开浏览 API 公开浏览 API

View File

@@ -659,7 +659,7 @@ async function referralUserId(
} }
function buildReferralUrl(code: string): string { function buildReferralUrl(code: string): string {
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:20015';
const url = new URL('/register', origin); const url = new URL('/register', origin);
url.searchParams.set('ref', code); url.searchParams.set('ref', code);
return url.toString(); return url.toString();
@@ -677,7 +677,7 @@ function getEmailConfig() {
} }
function buildTokenUrl(pathname: string, token: string): string { function buildTokenUrl(pathname: string, token: string): string {
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:20015';
const url = new URL(pathname, origin); const url = new URL(pathname, origin);
url.searchParams.set('token', token); url.searchParams.set('token', token);
return url.toString(); return url.toString();

View File

@@ -22,8 +22,8 @@ services:
environment: environment:
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
BACKEND_PORT: 3001 BACKEND_PORT: 3001
FRONTEND_ORIGIN: http://localhost:3000 FRONTEND_ORIGIN: http://localhost:20015
APP_ORIGIN: http://localhost:3000 APP_ORIGIN: http://localhost:20015
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
BACKEND_PUBLIC_ORIGIN: http://localhost:3001 BACKEND_PUBLIC_ORIGIN: http://localhost:3001
RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_API_KEY: ${RESEND_API_KEY:-}
@@ -42,8 +42,9 @@ services:
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
environment: environment:
VITE_API_BASE_URL: http://localhost:3001 VITE_API_BASE_URL: http://localhost:3001
VITE_SITE_URL: https://pokopiawiki.tootaio.com
ports: ports:
- "3000:3000" - "20015:20015"
depends_on: depends_on:
- backend - backend

View File

@@ -6,5 +6,5 @@ RUN corepack enable && pnpm install
COPY frontend/. . COPY frontend/. .
COPY package.json /app/package.json COPY package.json /app/package.json
COPY system-wordings.ts /app/system-wordings.ts COPY system-wordings.ts /app/system-wordings.ts
EXPOSE 3000 EXPOSE 20015
CMD ["pnpm", "run", "dev"] CMD ["pnpm", "run", "dev"]

View File

@@ -3,7 +3,32 @@
<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>Pokopia Wiki</title> <meta
name="description"
content="Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#6ccf32" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="canonical" href="%POKOPIA_SITE_URL%/pokemon" />
<meta property="og:site_name" content="Pokopia Wiki" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
<meta
property="og:description"
content="Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
<meta
name="twitter:description"
content="Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
<title>Pokopia Wiki - Pokemon Pokopia Guide</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -5,7 +5,7 @@
"packageManager": "pnpm@10.33.2", "packageManager": "pnpm@10.33.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --port 3000", "dev": "vite --host 0.0.0.0 --port 20015",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"lint": "vue-tsc --noEmit", "lint": "vue-tsc --noEmit",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -2,6 +2,8 @@ import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import { i18n } from './i18n'; import { i18n } from './i18n';
import { router } from './router'; import { router } from './router';
import { setupSeo } from './seo';
import './styles/main.css'; import './styles/main.css';
setupSeo(router);
createApp(App).use(i18n).use(router).mount('#app'); createApp(App).use(i18n).use(router).mount('#app');

View File

@@ -18,43 +18,174 @@ import RegisterView from '../views/RegisterView.vue';
import ResetPasswordView from '../views/ResetPasswordView.vue'; import ResetPasswordView from '../views/ResetPasswordView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue'; import VerifyEmailView from '../views/VerifyEmailView.vue';
import { api, getAuthToken, setAuthToken } from '../services/api'; import { api, getAuthToken, setAuthToken } from '../services/api';
import type { RouteSeoConfig } from '../seo';
const seo = (config: RouteSeoConfig) => config;
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', redirect: '/pokemon' }, { path: '/', redirect: '/pokemon' },
{ path: '/pokemon', name: 'pokemon-list', component: PokemonList }, { path: '/pokemon', name: 'pokemon-list', component: PokemonList, meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) } },
{ path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiredPermission: 'pokemon.create', editorModal: true } }, {
{ path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiredPermission: 'pokemon.update', editorModal: true } }, path: '/pokemon/new',
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail }, name: 'pokemon-new',
{ path: '/habitats', name: 'habitat-list', component: HabitatList }, component: PokemonList,
{ path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiredPermission: 'habitats.create', editorModal: true } }, meta: {
{ path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiredPermission: 'habitats.update', editorModal: true } }, requiredPermission: 'pokemon.create',
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail }, editorModal: true,
{ path: '/items', name: 'item-list', component: ItemsList }, seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
{ path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiredPermission: 'items.create', editorModal: true } }, }
{ path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiredPermission: 'items.update', editorModal: true } }, },
{ path: '/items/:id', name: 'item-detail', component: ItemDetail }, {
{ path: '/recipes', name: 'recipe-list', component: RecipeList }, path: '/pokemon/:id/edit',
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiredPermission: 'recipes.create', editorModal: true } }, name: 'pokemon-edit',
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiredPermission: 'recipes.update', editorModal: true } }, component: PokemonDetail,
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail }, meta: {
{ path: '/automation', name: 'automation', component: ComingSoonView, props: { page: 'automation' } }, requiredPermission: 'pokemon.update',
{ path: '/dish', name: 'dish', component: ComingSoonView, props: { page: 'dish' } }, editorModal: true,
{ path: '/events', name: 'events', component: ComingSoonView, props: { page: 'events' } }, seo: seo({
{ path: '/actions', name: 'actions', component: ComingSoonView, props: { page: 'actions' } }, titleKey: 'pages.pokemon.editKicker',
{ path: '/dream-island', name: 'dream-island', component: ComingSoonView, props: { page: 'dreamIsland' } }, descriptionKey: 'pages.pokemon.editSubtitle',
{ path: '/clothes', name: 'clothes', component: ComingSoonView, props: { page: 'clothes' } }, canonicalPath: (route) => `/pokemon/${String(route.params.id)}`,
{ path: '/checklist', component: DailyChecklistView }, noindex: true
{ path: '/life', component: LifeView }, })
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access' } }, }
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } }, },
{ path: '/profile/:id', component: UserProfileView }, { path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
{ path: '/login', component: LoginView }, { path: '/habitats', name: 'habitat-list', component: HabitatList, meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) } },
{ path: '/forgot-password', component: ForgotPasswordView }, {
{ path: '/reset-password', component: ResetPasswordView }, path: '/habitats/new',
{ path: '/register', component: RegisterView }, name: 'habitat-new',
{ path: '/verify-email', component: VerifyEmailView } component: HabitatList,
meta: {
requiredPermission: 'habitats.create',
editorModal: true,
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true })
}
},
{
path: '/habitats/:id/edit',
name: 'habitat-edit',
component: HabitatDetail,
meta: {
requiredPermission: 'habitats.update',
editorModal: true,
seo: seo({
titleKey: 'pages.habitats.detailKicker',
descriptionKey: 'pages.habitats.editSubtitle',
canonicalPath: (route) => `/habitats/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
{ path: '/items', name: 'item-list', component: ItemsList, meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/items/new',
name: 'item-new',
component: ItemsList,
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
}
},
{
path: '/items/:id/edit',
name: 'item-edit',
component: ItemDetail,
meta: {
requiredPermission: 'items.update',
editorModal: true,
seo: seo({
titleKey: 'pages.items.editKicker',
descriptionKey: 'pages.items.editSubtitle',
canonicalPath: (route) => `/items/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/recipes/new',
name: 'recipe-new',
component: RecipeList,
meta: {
requiredPermission: 'recipes.create',
editorModal: true,
seo: seo({ titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true })
}
},
{
path: '/recipes/:id/edit',
name: 'recipe-edit',
component: RecipeDetail,
meta: {
requiredPermission: 'recipes.update',
editorModal: true,
seo: seo({
titleKey: 'pages.recipes.editKicker',
descriptionKey: 'pages.recipes.editSubtitle',
canonicalPath: (route) => `/recipes/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail, meta: { seo: seo({ titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/automation',
name: 'automation',
component: ComingSoonView,
props: { page: 'automation' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }) }
},
{
path: '/dish',
name: 'dish',
component: ComingSoonView,
props: { page: 'dish' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dish.title', descriptionKey: 'pages.comingSoon.sections.dish.subtitle', noindex: true }) }
},
{
path: '/events',
name: 'events',
component: ComingSoonView,
props: { page: 'events' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }) }
},
{
path: '/actions',
name: 'actions',
component: ComingSoonView,
props: { page: 'actions' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }) }
},
{
path: '/dream-island',
name: 'dream-island',
component: ComingSoonView,
props: { page: 'dreamIsland' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }) }
},
{
path: '/clothes',
name: 'clothes',
component: ComingSoonView,
props: { page: 'clothes' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }) }
},
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access', seo: seo({ titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }) } },
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true, seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }) } },
{ path: '/profile/:id', component: UserProfileView, meta: { seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }) } },
{ path: '/login', component: LoginView, meta: { seo: seo({ titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }) } },
{ path: '/forgot-password', component: ForgotPasswordView, meta: { seo: seo({ titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }) } },
{ path: '/reset-password', component: ResetPasswordView, meta: { seo: seo({ titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }) } },
{ path: '/register', component: RegisterView, meta: { seo: seo({ titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }) } },
{ path: '/verify-email', component: VerifyEmailView, meta: { seo: seo({ titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }) } }
], ],
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition; if (savedPosition) return savedPosition;

182
frontend/src/seo.ts Normal file
View File

@@ -0,0 +1,182 @@
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
import { getCurrentLocale, i18n, onLocaleChange } from './i18n';
const siteName = 'Pokopia Wiki';
const defaultCanonicalPath = '/pokemon';
const defaultImagePath = '/seo/pokopia-hero.jpg';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
type TranslationValues = Record<string, string | number>;
export type RouteSeoConfig = {
title?: string;
titleKey?: string;
description?: string;
descriptionKey?: string;
canonicalPath?: string | ((route: RouteLocationNormalizedLoaded) => string);
image?: string;
noindex?: boolean;
};
export type SeoConfig = {
title?: string;
description?: string;
canonicalPath?: string;
image?: string | null;
noindex?: boolean;
};
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
function configuredSiteUrl(): string {
const fromEnv = import.meta.env.VITE_SITE_URL;
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
return normalizeSiteUrl(fromEnv);
}
if (typeof window !== 'undefined' && window.location.origin) {
return normalizeSiteUrl(window.location.origin);
}
return fallbackSiteUrl;
}
function normalizeSiteUrl(value: string): string {
return value.trim().replace(/\/+$/, '') || fallbackSiteUrl;
}
function normalizePath(value: string | undefined): string {
const path = value?.trim() || defaultCanonicalPath;
return path.startsWith('/') ? path : `/${path}`;
}
export function absoluteUrl(value: string): string {
try {
return new URL(value, `${configuredSiteUrl()}/`).toString();
} catch {
return `${configuredSiteUrl()}${normalizePath(value)}`;
}
}
function metaTitle(title?: string): string {
const cleanTitle = title?.trim();
if (!cleanTitle || cleanTitle === siteName) {
return siteName;
}
return `${cleanTitle} | ${siteName}`;
}
function metaDescription(description?: string): string {
return description?.trim() || translate('seo.siteDescription');
}
function localeForOpenGraph(locale: string): string {
if (locale === 'en') {
return 'en_US';
}
return locale.replace('-', '_');
}
function setMeta(attribute: 'name' | 'property', key: string, content: string): void {
let element = document.head.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`);
if (!element) {
element = document.createElement('meta');
element.setAttribute(attribute, key);
document.head.appendChild(element);
}
element.setAttribute('content', content);
}
function setCanonical(href: string): void {
let element = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!element) {
element = document.createElement('link');
element.setAttribute('rel', 'canonical');
document.head.appendChild(element);
}
element.setAttribute('href', href);
}
function setStructuredData(title: string, description: string, canonicalUrl: string): void {
let element = document.getElementById('pokopia-structured-data') as HTMLScriptElement | null;
if (!element) {
element = document.createElement('script');
element.id = 'pokopia-structured-data';
element.type = 'application/ld+json';
document.head.appendChild(element);
}
element.textContent = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description,
url: canonicalUrl,
isPartOf: {
'@type': 'WebSite',
name: siteName,
url: absoluteUrl('/')
}
});
}
export function applySeo(config: SeoConfig = {}): void {
if (typeof document === 'undefined') {
return;
}
const title = metaTitle(config.title);
const description = metaDescription(config.description);
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
const noindex = config.noindex === true;
const robots = noindex ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale();
document.title = title;
setMeta('name', 'description', description);
setMeta('name', 'robots', robots);
setMeta('name', 'twitter:card', 'summary_large_image');
setMeta('name', 'twitter:title', title);
setMeta('name', 'twitter:description', description);
setMeta('name', 'twitter:image', imageUrl);
setMeta('property', 'og:site_name', siteName);
setMeta('property', 'og:type', 'website');
setMeta('property', 'og:title', title);
setMeta('property', 'og:description', description);
setMeta('property', 'og:url', canonicalUrl);
setMeta('property', 'og:image', imageUrl);
setMeta('property', 'og:locale', localeForOpenGraph(locale));
setCanonical(canonicalUrl);
setStructuredData(title, description, canonicalUrl);
}
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath =
typeof routeSeo?.canonicalPath === 'function'
? routeSeo.canonicalPath(route)
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
applySeo({
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description,
canonicalPath,
image: routeSeo?.image,
noindex: routeSeo?.noindex
});
}
export function setupSeo(router: Router): void {
router.afterEach((to) => {
applyRouteSeo(to);
});
if (typeof window !== 'undefined') {
onLocaleChange(() => {
applyRouteSeo(router.currentRoute.value);
});
}
}

View File

@@ -12,6 +12,7 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat } from '../icons'; import { iconBack, iconEdit, iconHabitat } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api'; import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
import HabitatEdit from './HabitatEdit.vue'; import HabitatEdit from './HabitatEdit.vue';
@@ -116,7 +117,17 @@ const pokemonRows = computed<PokemonRow[]>(() => {
}); });
async function loadHabitatDetail() { async function loadHabitatDetail() {
habitat.value = await api.habitatDetail(String(route.params.id)); const nextHabitat = await api.habitatDetail(String(route.params.id));
habitat.value = nextHabitat;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextHabitat.name} - ${t('pages.habitats.title')}`,
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
canonicalPath: `/habitats/${nextHabitat.id}`,
image: nextHabitat.image?.url
});
}
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -12,6 +12,7 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons'; import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api'; import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue'; import ItemEdit from './ItemEdit.vue';
@@ -49,7 +50,17 @@ const customization = computed(() => {
}); });
async function loadItemDetail() { async function loadItemDetail() {
item.value = await api.itemDetail(String(route.params.id)); const nextItem = await api.itemDetail(String(route.params.id));
item.value = nextItem;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t('pages.items.title')}`,
description: t('seo.itemDetailDescription', { name: nextItem.name }),
canonicalPath: `/items/${nextItem.id}`,
image: nextItem.image?.url
});
}
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -14,6 +14,7 @@ import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons'; import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api'; import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api';
import PokemonEdit from './PokemonEdit.vue'; import PokemonEdit from './PokemonEdit.vue';
@@ -221,6 +222,15 @@ async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id)); const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon; pokemon.value = nextPokemon;
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id); relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextPokemon.name} - ${t('pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }),
canonicalPath: `/pokemon/${nextPokemon.id}`,
image: nextPokemon.image?.url
});
}
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -11,6 +11,7 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconRecipe } from '../icons'; import { iconBack, iconEdit, iconRecipe } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api'; import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue'; import RecipeEdit from './RecipeEdit.vue';
@@ -42,7 +43,17 @@ const recipeSubtitle = computed(() => {
}); });
async function loadRecipeDetail() { async function loadRecipeDetail() {
recipe.value = await api.recipeDetail(String(route.params.id)); const nextRecipe = await api.recipeDetail(String(route.params.id));
recipe.value = nextRecipe;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextRecipe.name} - ${t('pages.recipes.title')}`,
description: t('seo.recipeDetailDescription', { name: nextRecipe.name }),
canonicalPath: `/recipes/${nextRecipe.id}`,
image: nextRecipe.item.image?.url
});
}
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -1,9 +1,104 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv, type PluginOption } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
export default defineConfig({ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
plugins: [vue()], const frontendPort = 20015;
server: { const sitemapPaths = ['/pokemon', '/habitats', '/items', '/recipes', '/checklist', '/life'];
port: 3000 const robotsDisallowPaths = [
'/admin',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
'/pokemon/new',
'/pokemon/*/edit',
'/habitats/new',
'/habitats/*/edit',
'/items/new',
'/items/*/edit',
'/recipes/new',
'/recipes/*/edit',
'/automation',
'/dish',
'/events',
'/actions',
'/dream-island',
'/clothes'
];
function normalizeSiteUrl(value: string | undefined): string {
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
}
function robotsTxt(siteUrl: string): string {
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
}
function sitemapXml(siteUrl: string): string {
const urls = sitemapPaths
.map(
(path) => ` <url>
<loc>${siteUrl}${path}</loc>
<changefreq>weekly</changefreq>
</url>`
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
}
function seoFilesPlugin(siteUrl: string): PluginOption {
return {
name: 'pokopia-seo-files',
transformIndexHtml(html) {
return html.replaceAll('%POKOPIA_SITE_URL%', siteUrl);
},
configureServer(server) {
server.middlewares.use((request, response, next) => {
if (request.url === '/robots.txt') {
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.end(robotsTxt(siteUrl));
return;
} }
if (request.url === '/sitemap.xml') {
response.setHeader('Content-Type', 'application/xml; charset=utf-8');
response.end(sitemapXml(siteUrl));
return;
}
next();
});
},
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'robots.txt',
source: robotsTxt(siteUrl)
});
this.emitFile({
type: 'asset',
fileName: 'sitemap.xml',
source: sitemapXml(siteUrl)
});
}
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const siteUrl = normalizeSiteUrl(process.env.VITE_SITE_URL ?? env.VITE_SITE_URL);
return {
plugins: [vue(), seoFilesPlugin(siteUrl)],
server: {
port: frontendPort
}
};
}); });

View File

@@ -64,6 +64,18 @@ export const systemWordingMessages = {
logout: 'Log out', logout: 'Log out',
register: 'Register' register: 'Register'
}, },
seo: {
siteDescription:
'Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
pokemonDetailDescription:
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
itemDetailDescription:
'Browse {name} item details in Pokopia Wiki, including category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.',
habitatDetailDescription:
'View {name} habitat details in Pokopia Wiki, including recipes, possible Pokemon, maps, time, weather, discussions, and edit history.',
recipeDetailDescription:
'View the {name} recipe in Pokopia Wiki, including the result item, acquisition methods, materials, discussions, and edit history.'
},
auth: { auth: {
accountAccess: 'Trainer Pass', accountAccess: 'Trainer Pass',
email: 'Email', email: 'Email',
@@ -836,6 +848,13 @@ export const systemWordingMessages = {
logout: '退出', logout: '退出',
register: '注册' register: '注册'
}, },
seo: {
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、栖息地、物品、材料单、每日清单和 Life 社区动态。',
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。'
},
auth: { auth: {
accountAccess: 'Trainer Pass', accountAccess: 'Trainer Pass',
email: '邮箱', email: '邮箱',