From 7bbfe9a6984bcca1d7d345b31137c5b694bbc038 Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Thu, 23 Jul 2026 22:16:32 +0330
Subject: [PATCH] feat(metadata): enhance SEO fields for blog and blogs pages
---
app/blog/[slug]/page.js | 68 ++++++++++++++++++++++++++++++++++++-----
app/blogs/page.js | 8 +++++
2 files changed, 69 insertions(+), 7 deletions(-)
diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js
index 286d9a5..1004ef1 100644
--- a/app/blog/[slug]/page.js
+++ b/app/blog/[slug]/page.js
@@ -37,25 +37,43 @@ export async function generateMetadata({ params }) {
const blogCity = findCityById(extractEntityCityId(blog));
const brand = blogCity?.site_name || siteName;
- const title = `${blog.title} | ${brand}`;
+ // meta_title از فیلد اختصاصی SEO (اگر پایپلاین/ادمین ست کرده باشد) وگرنه عنوان.
+ const title = `${blog.meta_title || blog.title} | ${brand}`;
const rawText = (blog.summary || blog.body || "")
.replace(/<[^>]*>/g, "")
.trim();
const baseDescription = rawText.slice(0, 160) || blog.title;
- // نام شهر فقط برای پست شهریافته؛ پست سراسری هیچ شهری نمیگیرد.
- const description = blogCity
- ? `${baseDescription}`.slice(0, 150) + ` | ${blogCity.name}`
- : baseDescription;
- const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG;
+ // فیلد meta_description اختصاصی مقدم است؛ وگرنه از خلاصه/متن استخراج میشود.
+ const description =
+ blog.meta_description ||
+ (blogCity
+ ? `${baseDescription}`.slice(0, 150) + ` | ${blogCity.name}`
+ : baseDescription);
+ // کلیدواژهها از فیلدهای SEO (اصلی + فرعی) + تگها.
+ const keywords = [
+ blog.primary_keyword,
+ ...(Array.isArray(blog.secondary_keywords) ? blog.secondary_keywords : []),
+ ...(Array.isArray(blog.tags)
+ ? blog.tags.map((t) => (typeof t === "string" ? t : t?.name))
+ : []),
+ ].filter(Boolean);
+ const image = blog.og_image
+ ? imageUrl(blog.og_image)
+ : blog.image_url
+ ? imageUrl(blog.image_url)
+ : FALLBACK_IMG;
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical میشود؛
// پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical میماند.
+ // فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپلاین) مقدم است.
const origin = await getEntityOrigin(extractEntityCityId(blog));
+ const canonical = blog.canonical_url || `${origin}/blog/${slug}`;
return {
title,
description,
- alternates: { canonical: `${origin}/blog/${slug}` },
+ ...(keywords.length && { keywords }),
+ alternates: { canonical },
openGraph: {
title,
description,
@@ -63,6 +81,9 @@ export async function generateMetadata({ params }) {
...(blog.created_at && {
publishedTime: new Date(blog.created_at * 1000).toISOString(),
}),
+ ...(blog.updated_at && {
+ modifiedTime: new Date(blog.updated_at * 1000).toISOString(),
+ }),
images: [image],
},
twitter: {
@@ -97,16 +118,27 @@ async function Blog({ params }) {
.slice(0, 4)
.map(normalizeBlog);
+ const brand = blogCity?.site_name || "نوبت 724";
+ const articleDescription =
+ blog?.meta_description ||
+ (blog?.body?.value || blog?.summary || "").replace(/<[^>]*>/g, "").trim().slice(0, 160);
+
const jsonLd = blog
? {
"@context": "https://schema.org",
"@type": "Article",
headline: blog.title,
+ ...(articleDescription && { description: articleDescription }),
...(blog.images?.[0]?.url && { image: imageUrl(blog.images[0].url) }),
+ mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` },
...(blog.created && {
datePublished: new Date(blog.created * 1000).toISOString(),
}),
+ ...(blog.updated_at && {
+ dateModified: new Date(blog.updated_at * 1000).toISOString(),
+ }),
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
+ publisher: { "@type": "Organization", name: brand },
// پست شهریافته حوزهٔ جغرافیاییاش را اعلام میکند؛ پست سراسری این فیلد را ندارد.
...(blogCity && {
spatialCoverage: { "@type": "Place", name: blogCity.name },
@@ -114,6 +146,22 @@ async function Blog({ params }) {
}
: null;
+ // FAQPage از فیلد faq (سؤال/جواب) — فقط اگر مقاله FAQ داشته باشد.
+ const faqItems = Array.isArray(blog?.faq)
+ ? blog.faq.filter((f) => f?.q && f?.a)
+ : [];
+ const faqJsonLd = faqItems.length
+ ? {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faqItems.map((f) => ({
+ "@type": "Question",
+ name: f.q,
+ acceptedAnswer: { "@type": "Answer", text: f.a },
+ })),
+ }
+ : null;
+
const breadcrumbJsonLd = blog
? {
"@context": "https://schema.org",
@@ -140,6 +188,12 @@ async function Blog({ params }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
)}
+ {faqJsonLd && (
+
+ )}
diff --git a/app/blogs/page.js b/app/blogs/page.js
index b55392f..5074d33 100644
--- a/app/blogs/page.js
+++ b/app/blogs/page.js
@@ -8,9 +8,17 @@ export async function generateMetadata() {
const title = `مقالات و اخبار پزشکی | ${siteName}`;
const description = `جدیدترین مقالات، اخبار و راهنماهای پزشکی. اطلاعات تخصصی در حوزه سلامت و پزشکی از متخصصان ${siteName}.`;
const image = "/assets/images/og-image.png";
+ // کلیدواژههای شهر از city.json (رشته یا آرایه).
+ const rawKeywords = matchedCity?.keywords;
+ const keywords = Array.isArray(rawKeywords)
+ ? rawKeywords
+ : typeof rawKeywords === "string" && rawKeywords.trim()
+ ? rawKeywords.split(",").map((k) => k.trim()).filter(Boolean)
+ : undefined;
return {
title,
description,
+ ...(keywords?.length && { keywords }),
// C1-a — لیست بلاگ per-domain است (پستهای همان شهر + سراسری)؛
// canonical لایهٔ layout (self روی همان دامنه) درست است.
openGraph: { title, description, images: [image] },