diff --git a/.env.example b/.env.example index 6b5d451..b459706 100644 --- a/.env.example +++ b/.env.example @@ -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 " diff --git a/DESIGN.md b/DESIGN.md index 55923f0..e9d0c20 100644 --- a/DESIGN.md +++ b/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: diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 1d4df45..99fe0da 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -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(); diff --git a/docker-compose.yml b/docker-compose.yml index 0c60757..85f4149 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9c1efa5..e2933df 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html index b8f4327..67793e9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,32 @@ - Pokopia Wiki + + + + + + + + + + + + + + + + + Pokopia Wiki - Pokemon Pokopia Guide
diff --git a/frontend/package.json b/frontend/package.json index 0638adb..60ad279 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..93f6bff Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/seo/pokopia-hero.jpg b/frontend/public/seo/pokopia-hero.jpg new file mode 100644 index 0000000..07f5c20 Binary files /dev/null and b/frontend/public/seo/pokopia-hero.jpg differ diff --git a/frontend/public/seo/pokopia-logo.png b/frontend/public/seo/pokopia-logo.png new file mode 100644 index 0000000..3e8ee66 Binary files /dev/null and b/frontend/public/seo/pokopia-logo.png differ diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 9b52733..4893681 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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'); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c638af5..9b10f62 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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; diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts new file mode 100644 index 0000000..fd0f276 --- /dev/null +++ b/frontend/src/seo.ts @@ -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; + +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(`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('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); + }); + } +} diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue index 40adbaa..db7f82f 100644 --- a/frontend/src/views/HabitatDetail.vue +++ b/frontend/src/views/HabitatDetail.vue @@ -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(() => { }); 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 () => { diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 8f84edb..7046dd5 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -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 () => { diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index c5f905e..45cba75 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -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 () => { diff --git a/frontend/src/views/RecipeDetail.vue b/frontend/src/views/RecipeDetail.vue index d7fab23..03f6ae2 100644 --- a/frontend/src/views/RecipeDetail.vue +++ b/frontend/src/views/RecipeDetail.vue @@ -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 () => { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0e5c1ce..fdb7685 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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) => ` + ${siteUrl}${path} + weekly + ` + ) + .join('\n'); + + return ` + +${urls} + +`; +} + +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 + } + }; }); diff --git a/system-wordings.ts b/system-wordings.ts index 903595b..688df73 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -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: '邮箱',