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 && ( +