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:
@@ -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
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user