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:
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||
|
||||
definePageMeta({
|
||||
title: 'Threads'
|
||||
name: 'thread-detail',
|
||||
seo: {
|
||||
titleKey: 'pages.threads.title',
|
||||
descriptionKey: 'seo.threadsDescription',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/threads/${String(route.params.id)}`
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||
|
||||
definePageMeta({
|
||||
title: 'Threads'
|
||||
name: 'threads',
|
||||
seo: { titleKey: 'pages.threads.title', descriptionKey: 'seo.threadsDescription', canonicalPath: '/threads' }
|
||||
});
|
||||
</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';
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
@@ -68,6 +68,11 @@ async function detailSeo(
|
||||
image: recipe.item.image?.url
|
||||
};
|
||||
}
|
||||
|
||||
if (routeName === 'thread-detail') {
|
||||
const thread = await api.thread(routeId);
|
||||
return threadSeoConfig(thread, t);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const sitemapPaths = [
|
||||
'/dish',
|
||||
'/checklist',
|
||||
'/life',
|
||||
'/threads',
|
||||
'/project-updates',
|
||||
'/privacy-policy',
|
||||
'/terms-of-service',
|
||||
|
||||
@@ -9,7 +9,7 @@ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
let runtimeSiteUrl: string | null = null;
|
||||
|
||||
type TranslationValues = Record<string, string | number>;
|
||||
type Translator = (key: string, values?: TranslationValues) => string;
|
||||
export type Translator = (key: string, values?: TranslationValues) => string;
|
||||
|
||||
export type RouteSeoConfig = {
|
||||
title?: string;
|
||||
@@ -27,6 +27,8 @@ export type SeoConfig = {
|
||||
canonicalPath?: string;
|
||||
image?: string | null;
|
||||
noindex?: boolean;
|
||||
openGraphType?: 'website' | 'article';
|
||||
structuredData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ResolvedSeoConfig = {
|
||||
@@ -36,9 +38,21 @@ export type ResolvedSeoConfig = {
|
||||
imageUrl: string;
|
||||
robots: string;
|
||||
locale: string;
|
||||
openGraphType: 'website' | 'article';
|
||||
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>;
|
||||
let activeTranslator: Translator | 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 robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
|
||||
const locale = getCurrentLocale();
|
||||
const openGraphType = config.openGraphType ?? 'website';
|
||||
|
||||
return {
|
||||
title,
|
||||
@@ -128,7 +143,8 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
|
||||
imageUrl,
|
||||
robots,
|
||||
locale,
|
||||
structuredData: {
|
||||
openGraphType,
|
||||
structuredData: config.structuredData ?? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: title,
|
||||
@@ -157,7 +173,7 @@ export function resolvedSeoHead(seo: ResolvedSeoConfig) {
|
||||
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
|
||||
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
|
||||
{ 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-description', property: 'og:description', content: seo.description },
|
||||
{ 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 {
|
||||
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,
|
||||
iconUndo
|
||||
} from '../icons';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo, threadSeoConfig } from '../seo';
|
||||
import {
|
||||
api,
|
||||
threadWebSocketUrl,
|
||||
@@ -158,6 +159,9 @@ const currentThreadList = computed(() => {
|
||||
});
|
||||
});
|
||||
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 groups: MessageGroup[] = [];
|
||||
@@ -826,6 +830,12 @@ watch(activeThreadId, async () => {
|
||||
await loadMessages(true);
|
||||
});
|
||||
|
||||
watch(activeThread, (thread) => {
|
||||
if (thread) {
|
||||
applySeo(threadSeoConfig(thread, t));
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user