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:
@@ -3,8 +3,9 @@ POSTGRES_USER=pokopia
|
||||
POSTGRES_PASSWORD=pokopia
|
||||
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
||||
BACKEND_PORT=3001
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
APP_ORIGIN=http://localhost:3000
|
||||
FRONTEND_ORIGIN=http://localhost:20015
|
||||
APP_ORIGIN=http://localhost:20015
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
RESEND_API_KEY=
|
||||
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
||||
|
||||
22
DESIGN.md
22
DESIGN.md
@@ -656,6 +656,28 @@ API 暴露边界:
|
||||
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
|
||||
- 权限不足时前端可以隐藏或禁用对应操作;后端必须返回本地化 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:
|
||||
|
||||
@@ -659,7 +659,7 @@ async function referralUserId(
|
||||
}
|
||||
|
||||
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);
|
||||
url.searchParams.set('ref', code);
|
||||
return url.toString();
|
||||
@@ -677,7 +677,7 @@ function getEmailConfig() {
|
||||
}
|
||||
|
||||
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);
|
||||
url.searchParams.set('token', token);
|
||||
return url.toString();
|
||||
|
||||
@@ -22,8 +22,8 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||
BACKEND_PORT: 3001
|
||||
FRONTEND_ORIGIN: http://localhost:3000
|
||||
APP_ORIGIN: http://localhost:3000
|
||||
FRONTEND_ORIGIN: http://localhost:20015
|
||||
APP_ORIGIN: http://localhost:20015
|
||||
UPLOAD_DIR: /app/uploads
|
||||
BACKEND_PUBLIC_ORIGIN: http://localhost:3001
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
@@ -42,8 +42,9 @@ services:
|
||||
dockerfile: frontend/Dockerfile
|
||||
environment:
|
||||
VITE_API_BASE_URL: http://localhost:3001
|
||||
VITE_SITE_URL: https://pokopiawiki.tootaio.com
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "20015:20015"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ RUN corepack enable && pnpm install
|
||||
COPY frontend/. .
|
||||
COPY package.json /app/package.json
|
||||
COPY system-wordings.ts /app/system-wordings.ts
|
||||
EXPOSE 3000
|
||||
EXPOSE 20015
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
|
||||
@@ -3,7 +3,32 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"type": "module",
|
||||
"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",
|
||||
"lint": "vue-tsc --noEmit",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/seo/pokopia-hero.jpg
Normal file
BIN
frontend/public/seo/pokopia-hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 736 KiB |
BIN
frontend/public/seo/pokopia-logo.png
Normal file
BIN
frontend/public/seo/pokopia-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
@@ -2,6 +2,8 @@ import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { i18n } from './i18n';
|
||||
import { router } from './router';
|
||||
import { setupSeo } from './seo';
|
||||
import './styles/main.css';
|
||||
|
||||
setupSeo(router);
|
||||
createApp(App).use(i18n).use(router).mount('#app');
|
||||
|
||||
@@ -18,43 +18,174 @@ import RegisterView from '../views/RegisterView.vue';
|
||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||
import type { RouteSeoConfig } from '../seo';
|
||||
|
||||
const seo = (config: RouteSeoConfig) => config;
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/pokemon' },
|
||||
{ path: '/pokemon', name: 'pokemon-list', component: PokemonList },
|
||||
{ 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/:id', name: 'pokemon-detail', component: PokemonDetail },
|
||||
{ path: '/habitats', name: 'habitat-list', component: HabitatList },
|
||||
{ path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiredPermission: 'habitats.create', editorModal: true } },
|
||||
{ path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiredPermission: 'habitats.update', editorModal: true } },
|
||||
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail },
|
||||
{ path: '/items', name: 'item-list', component: ItemsList },
|
||||
{ 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: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiredPermission: 'recipes.create', editorModal: true } },
|
||||
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiredPermission: 'recipes.update', editorModal: true } },
|
||||
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
|
||||
{ path: '/automation', name: 'automation', component: ComingSoonView, props: { page: 'automation' } },
|
||||
{ path: '/dish', name: 'dish', component: ComingSoonView, props: { page: 'dish' } },
|
||||
{ path: '/events', name: 'events', component: ComingSoonView, props: { page: 'events' } },
|
||||
{ path: '/actions', name: 'actions', component: ComingSoonView, props: { page: 'actions' } },
|
||||
{ path: '/dream-island', name: 'dream-island', component: ComingSoonView, props: { page: 'dreamIsland' } },
|
||||
{ path: '/clothes', name: 'clothes', component: ComingSoonView, props: { page: 'clothes' } },
|
||||
{ path: '/checklist', component: DailyChecklistView },
|
||||
{ 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: '/login', component: LoginView },
|
||||
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||
{ path: '/reset-password', component: ResetPasswordView },
|
||||
{ path: '/register', component: RegisterView },
|
||||
{ path: '/verify-email', component: VerifyEmailView }
|
||||
{ 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,
|
||||
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/pokemon/:id/edit',
|
||||
name: 'pokemon-edit',
|
||||
component: PokemonDetail,
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.pokemon.editKicker',
|
||||
descriptionKey: 'pages.pokemon.editSubtitle',
|
||||
canonicalPath: (route) => `/pokemon/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
|
||||
{ path: '/habitats', name: 'habitat-list', component: HabitatList, meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) } },
|
||||
{
|
||||
path: '/habitats/new',
|
||||
name: 'habitat-new',
|
||||
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) {
|
||||
if (savedPosition) return savedPosition;
|
||||
|
||||
182
frontend/src/seo.ts
Normal file
182
frontend/src/seo.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
||||
import { applySeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
@@ -116,7 +117,17 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { applySeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
@@ -49,7 +50,17 @@ const customization = computed(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { applySeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
@@ -221,6 +222,15 @@ async function loadPokemonDetail() {
|
||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||
pokemon.value = nextPokemon;
|
||||
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 () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconRecipe } from '../icons';
|
||||
import { applySeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
|
||||
@@ -42,7 +43,17 @@ const recipeSubtitle = computed(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -1,9 +1,104 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv, type PluginOption } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3000
|
||||
}
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
const frontendPort = 20015;
|
||||
const sitemapPaths = ['/pokemon', '/habitats', '/items', '/recipes', '/checklist', '/life'];
|
||||
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
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -64,6 +64,18 @@ export const systemWordingMessages = {
|
||||
logout: 'Log out',
|
||||
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: {
|
||||
accountAccess: 'Trainer Pass',
|
||||
email: 'Email',
|
||||
@@ -836,6 +848,13 @@ export const systemWordingMessages = {
|
||||
logout: '退出',
|
||||
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: {
|
||||
accountAccess: 'Trainer Pass',
|
||||
email: '邮箱',
|
||||
|
||||
Reference in New Issue
Block a user