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

@@ -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');

View File

@@ -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
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 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 () => {

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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 () => {