feat(threads): add SEO metadata, sitemap, and structured data
Include /threads in sitemap and set canonical paths Generate DiscussionForumPosting structured data for thread details Add dynamic SEO updates for thread navigation and server-side rendering
This commit is contained in:
@@ -1174,12 +1174,15 @@ API 暴露边界:
|
|||||||
- `/checklist`
|
- `/checklist`
|
||||||
- `/life`
|
- `/life`
|
||||||
- `/life/:id`
|
- `/life/:id`
|
||||||
|
- `/threads`
|
||||||
|
- `/threads/:threadId`
|
||||||
- `/project-updates`
|
- `/project-updates`
|
||||||
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页、Life Post 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页、Life Post 详情页、Thread 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
||||||
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
||||||
|
- Threads 列表页使用 `/threads` canonical 并进入 sitemap;Thread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。
|
||||||
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
||||||
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
|
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
|
||||||
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。
|
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息、未审核 Thread Message、审核原因或实现说明。
|
||||||
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL,因此暂不输出 `hreflang`。
|
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL,因此暂不输出 `hreflang`。
|
||||||
|
|
||||||
## 部署与升级维护
|
## 部署与升级维护
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
import ThreadsView from '../../src/views/ThreadsView.vue';
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
title: 'Threads'
|
name: 'thread-detail',
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.threads.title',
|
||||||
|
descriptionKey: 'seo.threadsDescription',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/threads/${String(route.params.id)}`
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import ThreadsView from '../../src/views/ThreadsView.vue';
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
title: 'Threads'
|
name: 'threads',
|
||||||
|
seo: { titleKey: 'pages.threads.title', descriptionKey: 'seo.threadsDescription', canonicalPath: '/threads' }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { resolvedSeoHead, resolveSeo, type SeoConfig } from '../src/seo';
|
import { resolvedSeoHead, resolveSeo, threadSeoConfig, type SeoConfig } from '../src/seo';
|
||||||
import { api } from '../src/services/api';
|
import { api } from '../src/services/api';
|
||||||
|
|
||||||
export default defineNuxtPlugin(async () => {
|
export default defineNuxtPlugin(async () => {
|
||||||
@@ -68,6 +68,11 @@ async function detailSeo(
|
|||||||
image: recipe.item.image?.url
|
image: recipe.item.image?.url
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (routeName === 'thread-detail') {
|
||||||
|
const thread = await api.thread(routeId);
|
||||||
|
return threadSeoConfig(thread, t);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const sitemapPaths = [
|
|||||||
'/dish',
|
'/dish',
|
||||||
'/checklist',
|
'/checklist',
|
||||||
'/life',
|
'/life',
|
||||||
|
'/threads',
|
||||||
'/project-updates',
|
'/project-updates',
|
||||||
'/privacy-policy',
|
'/privacy-policy',
|
||||||
'/terms-of-service',
|
'/terms-of-service',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
|||||||
let runtimeSiteUrl: string | null = null;
|
let runtimeSiteUrl: string | null = null;
|
||||||
|
|
||||||
type TranslationValues = Record<string, string | number>;
|
type TranslationValues = Record<string, string | number>;
|
||||||
type Translator = (key: string, values?: TranslationValues) => string;
|
export type Translator = (key: string, values?: TranslationValues) => string;
|
||||||
|
|
||||||
export type RouteSeoConfig = {
|
export type RouteSeoConfig = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -27,6 +27,8 @@ export type SeoConfig = {
|
|||||||
canonicalPath?: string;
|
canonicalPath?: string;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
noindex?: boolean;
|
noindex?: boolean;
|
||||||
|
openGraphType?: 'website' | 'article';
|
||||||
|
structuredData?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvedSeoConfig = {
|
export type ResolvedSeoConfig = {
|
||||||
@@ -36,9 +38,21 @@ export type ResolvedSeoConfig = {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
robots: string;
|
robots: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
openGraphType: 'website' | 'article';
|
||||||
structuredData: Record<string, unknown>;
|
structuredData: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThreadSeoSummary = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
tags: Array<{ name: string }>;
|
||||||
|
messageCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
lastActiveAt: string;
|
||||||
|
author: { displayName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
|
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
|
||||||
let activeTranslator: Translator | null = null;
|
let activeTranslator: Translator | null = null;
|
||||||
let currentSeo: ResolvedSeoConfig | null = null;
|
let currentSeo: ResolvedSeoConfig | null = null;
|
||||||
@@ -120,6 +134,7 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
|
|||||||
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
|
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
|
||||||
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
|
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
|
||||||
const locale = getCurrentLocale();
|
const locale = getCurrentLocale();
|
||||||
|
const openGraphType = config.openGraphType ?? 'website';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -128,7 +143,8 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
robots,
|
robots,
|
||||||
locale,
|
locale,
|
||||||
structuredData: {
|
openGraphType,
|
||||||
|
structuredData: config.structuredData ?? {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
name: title,
|
name: title,
|
||||||
@@ -157,7 +173,7 @@ export function resolvedSeoHead(seo: ResolvedSeoConfig) {
|
|||||||
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
|
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
|
||||||
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
|
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
|
||||||
{ key: 'og-site-name', property: 'og:site_name', content: siteName },
|
{ key: 'og-site-name', property: 'og:site_name', content: siteName },
|
||||||
{ key: 'og-type', property: 'og:type', content: 'website' },
|
{ key: 'og-type', property: 'og:type', content: seo.openGraphType },
|
||||||
{ key: 'og-title', property: 'og:title', content: seo.title },
|
{ key: 'og-title', property: 'og:title', content: seo.title },
|
||||||
{ key: 'og-description', property: 'og:description', content: seo.description },
|
{ key: 'og-description', property: 'og:description', content: seo.description },
|
||||||
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
|
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
|
||||||
@@ -219,3 +235,39 @@ export function applySeo(config: SeoConfig = {}): void {
|
|||||||
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
|
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
|
||||||
applySeo(routeSeoConfig(route));
|
applySeo(routeSeoConfig(route));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function threadSeoConfig(thread: ThreadSeoSummary, translator: Translator): SeoConfig {
|
||||||
|
const title = thread.title.trim() || translator('pages.threads.title');
|
||||||
|
const canonicalPath = `/threads/${thread.id}`;
|
||||||
|
const keywords = thread.tags.map((tag) => tag.name.trim()).filter(Boolean).join(', ');
|
||||||
|
const description = translator('seo.threadDetailDescription', { title, count: thread.messageCount });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${title} - ${translator('pages.threads.title')}`,
|
||||||
|
description,
|
||||||
|
canonicalPath,
|
||||||
|
openGraphType: 'article',
|
||||||
|
structuredData: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'DiscussionForumPosting',
|
||||||
|
headline: title,
|
||||||
|
description,
|
||||||
|
url: absoluteUrl(canonicalPath),
|
||||||
|
datePublished: thread.createdAt,
|
||||||
|
dateModified: thread.lastActiveAt,
|
||||||
|
inLanguage: thread.languageCode,
|
||||||
|
keywords: keywords || undefined,
|
||||||
|
author: thread.author ? { '@type': 'Person', name: thread.author.displayName } : undefined,
|
||||||
|
interactionStatistic: {
|
||||||
|
'@type': 'InteractionCounter',
|
||||||
|
interactionType: { '@type': 'CommentAction' },
|
||||||
|
userInteractionCount: thread.messageCount
|
||||||
|
},
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: translator('pages.threads.title'),
|
||||||
|
url: absoluteUrl('/threads')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
iconThreads,
|
iconThreads,
|
||||||
iconUndo
|
iconUndo
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
|
import { applySeo, resolvedSeoHead, resolveSeo, threadSeoConfig } from '../seo';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
threadWebSocketUrl,
|
threadWebSocketUrl,
|
||||||
@@ -158,6 +159,9 @@ const currentThreadList = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const detailModalOpen = computed(() => activeThread.value !== null);
|
const detailModalOpen = computed(() => activeThread.value !== null);
|
||||||
|
const threadSeo = computed(() => (activeThread.value ? resolveSeo(threadSeoConfig(activeThread.value, t)) : null));
|
||||||
|
|
||||||
|
useHead(() => (threadSeo.value ? resolvedSeoHead(threadSeo.value) : {}));
|
||||||
|
|
||||||
const messageGroups = computed<MessageGroup[]>(() => {
|
const messageGroups = computed<MessageGroup[]>(() => {
|
||||||
const groups: MessageGroup[] = [];
|
const groups: MessageGroup[] = [];
|
||||||
@@ -826,6 +830,12 @@ watch(activeThreadId, async () => {
|
|||||||
await loadMessages(true);
|
await loadMessages(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(activeThread, (thread) => {
|
||||||
|
if (thread) {
|
||||||
|
applySeo(threadSeoConfig(thread, t));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadAll();
|
void loadAll();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ export const systemWordingMessages = {
|
|||||||
seo: {
|
seo: {
|
||||||
siteDescription:
|
siteDescription:
|
||||||
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
|
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
|
||||||
|
threadsDescription:
|
||||||
|
'Browse Pokopia Wiki Threads for Pokemon Pokopia community discussions by channel, language, tags, and recent activity.',
|
||||||
|
threadDetailDescription:
|
||||||
|
'Read the {title} community thread in Pokopia Wiki Threads, including public discussion, channel tags, language, and {count} visible messages.',
|
||||||
pokemonDetailDescription:
|
pokemonDetailDescription:
|
||||||
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
||||||
itemDetailDescription:
|
itemDetailDescription:
|
||||||
@@ -1587,6 +1591,8 @@ export const systemWordingMessages = {
|
|||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
|
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
|
||||||
|
threadsDescription: '按频道、语言、标签和最近活跃浏览 Pokopia Wiki 讨论,查看 Pokemon Pokopia 社区帖子。',
|
||||||
|
threadDetailDescription: '查看 Pokopia Wiki 讨论中的 {title} 帖子,包含公开讨论、频道标签、语言和 {count} 条可见消息。',
|
||||||
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
||||||
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
||||||
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
|
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
|
||||||
|
|||||||
Reference in New Issue
Block a user