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

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