feat(blog): update SEO metadata and improve RSS feed handling for blog posts

This commit is contained in:
mahdiarghyani
2025-12-14 11:56:37 +03:30
parent 13031465e3
commit 25aa41bcb8
5 changed files with 30 additions and 213 deletions
-195
View File
@@ -1,195 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const { data: page, error } = await useAsyncData(route.path, () => queryContent(route.path).findOne())
if (error.value) {
throw createError({
statusCode: 404,
message: 'Page not found',
fatal: true,
})
}
useSeoMeta({
title: page.value?.title,
description: page.value?.description,
ogTitle: page.value?.title,
ogDescription: page.value?.description,
})
const { data: relatedArticles } = await useAsyncData(`content:related-articles:${page.value?.title}`, () => queryContent('/articles').where({ categories: { $in: page.value?.categories }, _extension: 'md' }).only(['_path', 'title', 'categories', 'description', 'publishedAt', 'image', 'authors']).sort({ publishedAt: -1 }).limit(3).find())
const appConfig = useAppConfig()
const runtimeConfig = useRuntimeConfig()
</script>
<template>
<UContainer
v-if="page"
>
<UPage>
<UPageHeader
class="max-w-5xl mx-auto"
:title="page.title"
:description="page.description"
>
<template #headline>
<!-- Waiting for https://github.com/nuxt/ui-pro/issues/114 -->
<!-- <dl>
<dt class="sr-only">
Categories
</dt>
<dd> -->
<template
v-for="(category, index) in page.categories"
:key="category"
>
<ULink
:to="`/categories/${category}`"
>
{{ formatCategory(category) }}
</ULink>
<span v-if="index < page.categories.length - 1">
-
</span>
</template>
<!-- </dd>
</dl> -->
</template>
<NuxtImg
v-if="page.image"
:src="page.image.src"
:alt="page.image.alt"
class="mt-8 w-full object-cover rounded-lg aspect-[16/9]"
>
<dl class="mt-8 flex justify-between text-stone-700 text-sm">
<dt class="sr-only">
Author
</dt>
<dd>
<ol class="space-x-4">
<li
v-for="author in page.authors"
:key="author.name"
>
<ULink
:to="author.social"
target="_blank"
rel="noopener"
class="flex items-center gap-2"
>
<UAvatar
:src="author.avatar"
:alt="author.name"
size="sm"
/>
<span>
{{ author.name }}
</span>
</ULink>
</li>
</ol>
</dd>
<dt class="sr-only">
Published at
</dt>
<dd>
<time :datetime="page.publishedAt">
{{ formatDate(page.publishedAt) }}
</time>
</dd>
</dl>
</nuxtimg>
</UPageHeader>
<div class="mt-8 max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-[96px_768px_1fr]">
<div class="lg:px-8 flex lg:flex-col lg:items-end gap-2">
<UTooltip text="Share on X">
<UButton
:to="`https://twitter.com/share?text=${page.title}&url=${runtimeConfig.app.name}${page._path}`"
target="_blank"
icon="i-simple-icons-x"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
<UTooltip text="Share on Facebook">
<UButton
:to="`https://www.facebook.com/sharer/sharer.php?u=${runtimeConfig.app.name}${page._path}&t=${page.title}`"
target="_blank"
icon="i-simple-icons-facebook"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
<UTooltip text="Share on LinkedIn">
<UButton
:to="`https://www.linkedin.com/shareArticle?url=${runtimeConfig.app.name}${page._path}&title=${page.title}`"
target="_blank"
icon="i-simple-icons-linkedin"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
</div>
<div class="mt-8 lg:mt-0 w-full">
<UPageBody
prose
:ui="{ wrapper: 'mt-0' }"
>
<ContentRenderer :value="page" />
</UPageBody>
</div>
<div class="row-start-2 lg:row-start-1 lg:col-start-3 lg:px-8 space-y-8">
<UButton
v-bind="appConfig.page.article.cta"
color="primary"
size="lg"
:ui="{ base: 'w-full justify-center' }"
class="hidden lg:inline-flex"
/>
<UDivider />
<UContentToc
:links="page.body?.toc?.links"
:ui="{ wrapper: 'top-4', container: { base: 'py-0 pb-3 lg:py-0 lg:pb-8' } }"
/>
<UDivider class="lg:hidden" />
</div>
</div>
<section v-if="relatedArticles">
<h2 class="text-2xl text-stone-900 font-bold">
Related Articles
</h2>
<div class="mt-3 border-b border-stone-200" />
<UPageGrid class="mt-8">
<ArticleCard
v-for="article in relatedArticles"
:key="article._path"
:to="article._path!"
:title="article.title!"
:description="article.description"
:date="article.publishedAt"
:image="article.image"
:authors="article.authors"
/>
</UPageGrid>
</section>
</UPage>
</UContainer>
</template>
+12 -2
View File
@@ -56,6 +56,8 @@ const siteUrl = 'https://aliarghyani.vercel.app' // TODO: Move to runtime config
// Custom meta tags // Custom meta tags
if (post.value) { if (post.value) {
const postData = post.value as any const postData = post.value as any
const canonicalPath = postData.path ? postData.path.replace(/^\/(en|fa)/, '') : ''
const canonicalUrl = canonicalPath ? `${siteUrl}${canonicalPath}` : siteUrl
useSeoMeta({ useSeoMeta({
title: `${postData.title} | ${t('blog.title')}`, title: `${postData.title} | ${t('blog.title')}`,
@@ -64,7 +66,7 @@ if (post.value) {
ogDescription: postData.description, ogDescription: postData.description,
ogImage: postData.image || '/img/blog/default-cover.jpg', ogImage: postData.image || '/img/blog/default-cover.jpg',
ogType: 'article', ogType: 'article',
ogUrl: `${siteUrl}${postData.path}`, ogUrl: canonicalUrl,
twitterCard: 'summary_large_image', twitterCard: 'summary_large_image',
twitterTitle: postData.title, twitterTitle: postData.title,
twitterDescription: postData.description, twitterDescription: postData.description,
@@ -77,6 +79,14 @@ if (post.value) {
// JSON-LD structured data // JSON-LD structured data
useHead({ useHead({
link: canonicalPath
? [
{
rel: 'canonical',
href: canonicalUrl
}
]
: [],
script: [ script: [
{ {
type: 'application/ld+json', type: 'application/ld+json',
@@ -140,7 +150,7 @@ if (post.value) {
</article> </article>
<!-- Share Buttons --> <!-- Share Buttons -->
<BlogShare :title="(post as any).title" :url="`${siteUrl}${(post as any).path}`" /> <BlogShare :title="(post as any).title" :url="`${siteUrl}${(post as any).path.replace(/^\/(en|fa)/, '')}`" />
<!-- Blog Navigation (Prev/Next) --> <!-- Blog Navigation (Prev/Next) -->
<BlogNavigation :prev="prevPost" :next="nextPost" /> <BlogNavigation :prev="prevPost" :next="nextPost" />
+3 -2
View File
@@ -175,9 +175,10 @@ export default defineNuxtConfig({
nitro: { nitro: {
prerender: { prerender: {
crawlLinks: true, crawlLinks: true,
routes: ['/', '/blog', '/fa/blog'], routes: ['/', '/blog', '/fa/blog', '/blog/rss.xml', '/fa/blog/rss.xml'],
failOnError: false, failOnError: false,
ignore: ['/_vercel/image'] // Avoid crawling and prerendering internal/asset endpoints (speeds up builds significantly)
ignore: ['/_vercel/image', '/_ipx', '/_nuxt', '/_i18n', '/__nuxt_content']
}, },
devProxy: { devProxy: {
host: '0.0.0.0' host: '0.0.0.0'
+8 -7
View File
@@ -2,14 +2,14 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const siteUrl = config.public.siteUrl || 'https://example.com' const siteUrl = config.public.siteUrl || 'https://example.com'
// Detect locale from path
const locale = 'en' const locale = 'en'
// Fetch published blog posts // In Nitro server routes, `queryCollection` expects the H3 event as the first argument.
const posts = await serverQueryContent(event, `${locale}/blog`) const allPosts = await queryCollection(event, 'blog').all()
.where({ draft: { $ne: true } })
.sort({ date: -1 }) const posts = allPosts
.find() .filter((post: any) => post.path?.startsWith('/en/blog/') && post.draft !== true)
.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime())
const escapeXml = (str: string) => { const escapeXml = (str: string) => {
return str return str
@@ -22,7 +22,8 @@ export default defineEventHandler(async (event) => {
const rssItems = posts const rssItems = posts
.map((post) => { .map((post) => {
const link = `${siteUrl}${post._path}` const routePath = post.path?.replace(/^\/en/, '') || ''
const link = `${siteUrl}${routePath}`
const pubDate = new Date(post.date).toUTCString() const pubDate = new Date(post.date).toUTCString()
return ` return `
+7 -7
View File
@@ -2,14 +2,14 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const siteUrl = config.public.siteUrl || 'https://example.com' const siteUrl = config.public.siteUrl || 'https://example.com'
// Persian locale
const locale = 'fa' const locale = 'fa'
// Fetch published blog posts // In Nitro server routes, `queryCollection` expects the H3 event as the first argument.
const posts = await serverQueryContent(event, `${locale}/blog`) const allPosts = await queryCollection(event, 'blog').all()
.where({ draft: { $ne: true } })
.sort({ date: -1 }) const posts = allPosts
.find() .filter((post: any) => post.path?.startsWith('/fa/blog/') && post.draft !== true)
.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime())
const escapeXml = (str: string) => { const escapeXml = (str: string) => {
return str return str
@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
const rssItems = posts const rssItems = posts
.map((post) => { .map((post) => {
const link = `${siteUrl}${post._path}` const link = `${siteUrl}${post.path}`
const pubDate = new Date(post.date).toUTCString() const pubDate = new Date(post.date).toUTCString()
return ` return `