mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-08-16 21:14:31 +03:30
feat(blog): update SEO metadata and improve RSS feed handling for blog posts
This commit is contained in:
@@ -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>
|
||||
@@ -56,6 +56,8 @@ const siteUrl = 'https://aliarghyani.vercel.app' // TODO: Move to runtime config
|
||||
// Custom meta tags
|
||||
if (post.value) {
|
||||
const postData = post.value as any
|
||||
const canonicalPath = postData.path ? postData.path.replace(/^\/(en|fa)/, '') : ''
|
||||
const canonicalUrl = canonicalPath ? `${siteUrl}${canonicalPath}` : siteUrl
|
||||
|
||||
useSeoMeta({
|
||||
title: `${postData.title} | ${t('blog.title')}`,
|
||||
@@ -64,7 +66,7 @@ if (post.value) {
|
||||
ogDescription: postData.description,
|
||||
ogImage: postData.image || '/img/blog/default-cover.jpg',
|
||||
ogType: 'article',
|
||||
ogUrl: `${siteUrl}${postData.path}`,
|
||||
ogUrl: canonicalUrl,
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: postData.title,
|
||||
twitterDescription: postData.description,
|
||||
@@ -77,6 +79,14 @@ if (post.value) {
|
||||
|
||||
// JSON-LD structured data
|
||||
useHead({
|
||||
link: canonicalPath
|
||||
? [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: canonicalUrl
|
||||
}
|
||||
]
|
||||
: [],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
@@ -140,7 +150,7 @@ if (post.value) {
|
||||
</article>
|
||||
|
||||
<!-- 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) -->
|
||||
<BlogNavigation :prev="prevPost" :next="nextPost" />
|
||||
|
||||
+3
-2
@@ -175,9 +175,10 @@ export default defineNuxtConfig({
|
||||
nitro: {
|
||||
prerender: {
|
||||
crawlLinks: true,
|
||||
routes: ['/', '/blog', '/fa/blog'],
|
||||
routes: ['/', '/blog', '/fa/blog', '/blog/rss.xml', '/fa/blog/rss.xml'],
|
||||
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: {
|
||||
host: '0.0.0.0'
|
||||
|
||||
@@ -2,14 +2,14 @@ export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = config.public.siteUrl || 'https://example.com'
|
||||
|
||||
// Detect locale from path
|
||||
const locale = 'en'
|
||||
|
||||
// Fetch published blog posts
|
||||
const posts = await serverQueryContent(event, `${locale}/blog`)
|
||||
.where({ draft: { $ne: true } })
|
||||
.sort({ date: -1 })
|
||||
.find()
|
||||
// In Nitro server routes, `queryCollection` expects the H3 event as the first argument.
|
||||
const allPosts = await queryCollection(event, 'blog').all()
|
||||
|
||||
const posts = allPosts
|
||||
.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) => {
|
||||
return str
|
||||
@@ -22,7 +22,8 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const rssItems = posts
|
||||
.map((post) => {
|
||||
const link = `${siteUrl}${post._path}`
|
||||
const routePath = post.path?.replace(/^\/en/, '') || ''
|
||||
const link = `${siteUrl}${routePath}`
|
||||
const pubDate = new Date(post.date).toUTCString()
|
||||
|
||||
return `
|
||||
|
||||
@@ -2,14 +2,14 @@ export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = config.public.siteUrl || 'https://example.com'
|
||||
|
||||
// Persian locale
|
||||
const locale = 'fa'
|
||||
|
||||
// Fetch published blog posts
|
||||
const posts = await serverQueryContent(event, `${locale}/blog`)
|
||||
.where({ draft: { $ne: true } })
|
||||
.sort({ date: -1 })
|
||||
.find()
|
||||
// In Nitro server routes, `queryCollection` expects the H3 event as the first argument.
|
||||
const allPosts = await queryCollection(event, 'blog').all()
|
||||
|
||||
const posts = allPosts
|
||||
.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) => {
|
||||
return str
|
||||
@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const rssItems = posts
|
||||
.map((post) => {
|
||||
const link = `${siteUrl}${post._path}`
|
||||
const link = `${siteUrl}${post.path}`
|
||||
const pubDate = new Date(post.date).toUTCString()
|
||||
|
||||
return `
|
||||
|
||||
Reference in New Issue
Block a user