- Introduced a new script `make-blog-cover.mjs` to create a default cover image for blog posts. - The image is generated in PNG format with dimensions 1200x630, suitable for Open Graph. - Utilizes the site's branding colors and logo from `public/nobat724.svg`. - Includes custom font styling using the Vazirmatn font. - The generated image is saved to `public/assets/images/blog-default-cover.png`.
22 KiB
سینک کامل صفحهٔ بلاگ با بکاند + تصویر پیشفرض برند + باطلسازی کش
پروژه
nobat724_front (سایت عمومی)
پرامپت همتا (cross-repo، اول اجرا شود): clinicpro/.claude/prompt/blog-admin-edit-and-dark-mode.md — اندپوینت ادمین و فراخوانندهٔ webhook آنجا ساخته میشود؛ مصرفکنندهٔ webhook اینجاست.
زمینه
کاربر گزارش داده صفحهٔ بلاگ سایت (https://nobat724.com/blog/<uuid>) همهٔ اطلاعاتی را که در پنل ادمین مدیریت میشود نشان نمیدهد، و مقالهٔ بدون تصویر شاخص با تصویر پیشفرض نامناسب نمایش داده میشود.
بررسی کد نشان میدهد بکاند در Blog::toArray() این فیلدها را برمیگرداند:
uuid, title, slug, summary, body, image_url, tags, sources, status,
review_status, reviewer, reviewed_at, review_note, topic_slug,
meta_title, meta_description, primary_keyword, secondary_keywords, faq,
internal_links, external_links, reading_time, canonical_url, og_image,
representation, author, city, created_at, updated_at
اما صفحهٔ بلاگ فقط title، author، created، tag[0] (در بردکرامب)، city و body را رندر میکند.
مشکل / هدف
مشکل ۱ — فیلدهایی که رندر نمیشوند
| فیلد بکاند | وضعیت فعلی در سایت |
|---|---|
summary (خلاصه) |
❌ فقط در متادیتا؛ در HTML صفحه هیچجا نیست |
tags |
⚠️ فقط تگ اول در بردکرامب (components/blog/head/index.js:5)؛ فهرست تگها نیست |
reading_time |
❌ رندر نمیشود |
sources (منابع، سیگنال E-E-A-T) |
❌ رندر نمیشود |
faq |
⚠️ فقط داخل JSON-LD (app/blog/[slug]/page.js:173)؛ در HTML قابلمشاهده نیست — گوگل برای rich result نیازمند حضور مرئی همان پرسش/پاسخهاست، پس FAQPage فعلی در معرض بیاعتبار شدن است |
internal_links / external_links |
❌ رندر نمیشود |
status |
✅ درست: پستِ غیرمنتشر از بکاند ۴۰۴ میگیرد |
slug / meta_* / canonical_url / og_image |
✅ در generateMetadata مصرف میشود |
نکتهٔ خلاف درخواست کاربر: بلاگ در بکاند دستهبندی (category) ندارد؛ فقط ستون json tags. پس «دستهبندیها» با نمایش تگها پوشش داده میشود. ساخت Entity دستهبندی تسک جدا و migration جدا میخواهد و در محدودهٔ این پرامپت نیست.
مشکل ۲ — تصویر پیشفرض
سه fallback ناهماهنگ و نامناسب در کد وجود دارد و در صفحهٔ جزئیات اصلاً fallback ای نیست:
components/blog/detail/Caption.js:7,10
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
...
{cover && cover.trim() !== "" && (
→ مقالهٔ بدون تصویر هیچ تصویری ندارد.
components/blogs/latestArticles/Article.js:8-10
const imageSrc = data.images?.[0]?.url
? imageUrl(data.images[0].url, "/assets/images/cover-blog-1.png")
: "/assets/images/cover-blog-1.png";
components/blog/relatedContent/Item.js:8-10
const cover = data?.images?.[0]?.url
? imageUrl(data.images[0].url)
: "/assets/images/cover-blog-1.png";
app/blog/[slug]/page.js:16
const FALLBACK_IMG = "/assets/images/og-image.png";
و پیشفرضِ خودِ هلپر یک آواتار انسان است (helper/index.js):
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
یعنی هر جای دیگری که imageUrl(blog.image_url) بدون آرگومان دوم صدا زده شود، عکسِ «کاربر» را برای مقاله نشان میدهد.
مشکل ۳ — تأخیر تا یک ساعت در نمایش تغییرات ادمین
app/blog/[slug]/page.js:21-32
const getBlog = cache(async (slug, cityId) => {
const query = cityId != null ? `?city_id=${cityId}` : "";
const res = await fetch(`${API_URL}/api/v1/blog/${slug}${query}`, {
next: { revalidate: 3600, tags: [`blog-${slug}`] },
});
tag تعریف شده ولی هیچکس آن را باطل نمیکند (app/api/ فقط auth دارد). پس تغییر ادمین تا ۶۰ دقیقه دیده نمیشود.
مشکل ۴ — استایل محتوای CKEditor حذف میشود
lib/sanitize.js تگهای figure/figcaption را در ALLOWED_TAGS ندارد و class/style را هم در ALLOWED_ATTR ندارد. خروجی جدول/تصویر CKEditor به شکل <figure class="table"><table>…</table></figure> است؛ DOMPurify با KEEP_CONTENT پیشفرض، محتوای داخلی را نگه میدارد ولی wrapper و کلاسها را حذف میکند → جدول و تصویرِ داخل متن بدون هیچ استایلی و چسبیده رندر میشوند. (این حذف داده نیست، حذف ساختار/استایل است — واقعیت را همینطور گزارش کن.)
معیار پذیرش
- ✅ موفق:
- در
/blog/<slug>یک مقالهٔ کامل: خلاصه، فهرست تگها، زمان مطالعه، تصویر شاخص، متن، بخش «سؤالات متداول» مرئی، بخش «منابع» با لینکهایsources— همه رندر میشوند و مقادیرشان دقیقاً با پاسخGET /api/v1/blog/{slug}یکی است. - مقالهٔ بدون
image_urlدر صفحهٔ جزئیات، کارتهای لیست، مقالات مرتبط و OG/Twitter همگی همان یک تصویر پیشفرض برنددار را نشان میدهند. POST /api/revalidateبا هدر صحیح →200 {"revalidated":true}و بلافاصله پس از آن، صفحهٔ بلاگ محتوای جدید را نشان میدهد (بدون انتظار یکساعته).npm run buildوnpm run lintسبز.
- در
- ❌ خطا:
POST /api/revalidateبدون هدر یا با سکرت غلط →401و هیچ باطلسازیای انجام نشود.POST /api/revalidateبا بدنهٔ نامعتبر (بدونtagsیاtagsغیرآرایه) →400.- اسلاگ ناموجود → همان
notFound()فعلی (۴۰۴)، بدون خطای رندر.
- ⚠️ مرزی:
- مقالهای با
faq: []وsources: []وtags: []→ هیچ سکشن خالی یا هدینگ بیمحتوا رندر نشود (نه «سؤالات متداول» خالی، نه<ul>خالی). - مقالهای که
reading_time = nullدارد → برچسب زمان مطالعه نمایش داده نشود. - مقالهٔ شهریافته روی دامنهٔ شهرِ دیگر → همچنان ۴۰۴ (رفتار فعلی
domainScopeCityIdنباید تغییر کند). image_urlمطلق (https://…) →imageUrl()باید دستنخورده برگرداند؛/uploads/...→ باNEXT_PUBLIC_API_URLپیشوند بخورد.og_imageوقتی ست است برimage_urlمقدم بماند (رفتار فعلیpage.js:75-79حفظ شود).
- مقالهای با
فایلهای مرتبط
| فایل | نقش |
|---|---|
nobat724_front/helper/index.js |
ثابت BLOG_FALLBACK_IMG + استفاده در normalizeBlog |
nobat724_front/components/blog/detail/Caption.js |
کاور با fallback + خلاصه + استایل محتوا |
nobat724_front/components/blog/head/index.js |
فهرست تگها + زمان مطالعه |
nobat724_front/components/blog/detail/Faq.js |
جدید — FAQ مرئی |
nobat724_front/components/blog/detail/Sources.js |
جدید — منابع |
nobat724_front/components/blog/detail/index.js |
چیدن کامپوننتهای جدید |
nobat724_front/components/blogs/latestArticles/Article.js, components/blog/relatedContent/Item.js |
یکسانسازی fallback |
nobat724_front/app/blog/[slug]/page.js |
FALLBACK_IMG مشترک + tag های کش |
nobat724_front/app/api/revalidate/route.js |
جدید — webhook باطلسازی |
nobat724_front/lib/sanitize.js |
افزودن figure/figcaption |
nobat724_front/app/globals.css |
استایل محتوای مقاله (.blog-content) |
nobat724_front/public/assets/images/blog-default-cover.png |
جدید — تصویر پیشفرض ۱۲۰۰×۶۳۰ |
وضعیت فعلی
nobat724_front/components/blog/detail/Caption.js (کل فایل):
import React from "react";
import Image from "next/image";
import { sanitizeHtml } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
function Caption({ data }) {
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
return (
<>
{cover && cover.trim() !== "" && (
<div className="relative w-full aspect-video rounded-[8px] overflow-hidden">
<Image src={cover} alt={data?.title || "blog cover"} fill className="object-cover" />
</div>
)}
{data?.body?.value && (
<div
className="my-[12px] sm:my-[16px] md:my-[20px] lg:my-[24px] text-[#525252] text-[14px] md:text-[15px] lg:text-[16px] font-normal leading-[26px] sm:leading-[28px] md:leading-[30px] lg:leading-[32px]"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(data.body.value) }}
/>
)}
</>
);
}
export default Caption;
nobat724_front/components/blog/detail/index.js (کل فایل):
import Caption from "./Caption";
import SocialMedia from "./SocialMedia";
import CommentUser from "./commentUser";
function Detail({ data }) {
return (
<div className="w-full lg:w-[68%]">
<Caption data={data} />
<SocialMedia />
{/* <CommentUser data={data} loading={false} /> */}
</div>
);
}
nobat724_front/helper/index.js (نرمالساز + هلپر تصویر):
export const normalizeBlog = (blog) => {
if (!blog) return null;
return {
...blog,
images: blog.image_url ? [{ url: blog.image_url }] : [],
tag: Array.isArray(blog.tags)
? blog.tags.map((t) => (typeof t === "string" ? { name: t } : t))
: [],
created: blog.created_at ?? blog.created ?? null,
body: blog.body && typeof blog.body === "object" ? blog.body : { value: blog.body ?? "" },
author: blog.author && typeof blog.author === "object" ? blog.author.name : blog.author ?? null,
};
};
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
if (!url) return fallback;
if (/^https?:\/\//.test(url)) return url;
if (url.startsWith("/uploads")) {
const base = process.env.NEXT_PUBLIC_API_URL || "";
return `${base}${url}`;
}
return url;
};
nobat724_front/lib/sanitize.js (بخش مرتبط):
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
"p", "br", "strong", "em", "b", "i", "u", "ul", "ol", "li", "a",
"h2", "h3", "h4", "h5", "blockquote", "img", "span", "div",
"table", "thead", "tbody", "tr", "td", "th",
],
ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"],
ALLOW_DATA_ATTR: false,
});
وظایف
۱. ساخت تصویر پیشفرض برنددار
sharp@0.34.5 از قبل در package.json هست. یک اسکریپت یکبارمصرف در scripts/make-blog-cover.mjs بنویس که از public/assets/images/logo.png روی پسزمینهٔ برند، فایل public/assets/images/blog-default-cover.png با ابعاد ۱۲۰۰×۶۳۰ (نسبت استاندارد Open Graph، سازگار با aspect-video) بسازد:
import sharp from "sharp";
const W = 1200, H = 630;
// رنگ برند را از app/globals.css یا tailwind.config برداشت کن — هاردکد نکن اگر توکن موجود است.
const BG = { r: 0x0f, g: 0x4c, b: 0x81, alpha: 1 };
const logo = await sharp("public/assets/images/logo.png").resize({ width: 420 }).toBuffer();
await sharp({ create: { width: W, height: H, channels: 4, background: BG } })
.composite([{ input: logo, gravity: "center" }])
.png()
.toFile("public/assets/images/blog-default-cover.png");
خروجی PNG باید کمیت شود (commit) و اسکریپت هم بماند تا قابل بازتولید باشد. اگر رنگ برند در CSS پیدا نشد، از رنگ لوگو نمونه بگیر و در گزارش بگو کدام مقدار استفاده شد.
نحوه تست: node scripts/make-blog-cover.mjs → فایل ساخته شود؛ sips -g pixelWidth -g pixelHeight public/assets/images/blog-default-cover.png → 1200 × 630. تصویر را باز کن و در گزارش بگو لوگو خوانا و مرکز است.
۲. یک منبع واحد برای fallback تصویر بلاگ
در helper/index.js:
// تصویر پیشفرض همهٔ مقالهها — لیست، کارت، جزئیات، OG و توییتر همگی همین را
// میگیرند تا هیچ مقالهای بدون تصویر (یا با آواتار کاربر) نمایش داده نشود.
export const BLOG_FALLBACK_IMG = "/assets/images/blog-default-cover.png";
export const blogCover = (blog) =>
imageUrl(blog?.og_image || blog?.image_url || blog?.images?.[0]?.url, BLOG_FALLBACK_IMG);
سپس جایگزینی در همهٔ نقاط مصرف:
components/blog/detail/Caption.js→const cover = blogCover(data);و شرط{cover && ...}حذف شود (همیشه تصویر هست).components/blogs/latestArticles/Article.js:8-10→const imageSrc = blogCover(data);components/blog/relatedContent/Item.js:8-10→const cover = blogCover(data);app/blog/[slug]/page.js:16,75-79→FALLBACK_IMGحذف و ازBLOG_FALLBACK_IMGاستفاده شود؛ منطق تقدمog_imageرویimage_urlداخلblogCoverاست، پس بلوک شرطی سهطبقهٔ فعلی ساده میشود.- ارجاعهای
cover-blog-1.png/user.pngبرای بلاگ حذف شوند. (فایلهایhead-blogs-*.pngدرcomponents/blogs/Head.jsهستند که کد مرده است — کل کامپوننتreturn nullمیکند و بقیهاش کامنت است؛ دست نزن، فقط در گزارش ذکر کن.)
نحوه تست: با npm run dev (روی http://yazd-nobat.localhost:3000) یک مقالهٔ بدون image_url باز کن → کاور پیشفرض دیده شود؛ curl -s <url> | grep 'og:image' → مسیر blog-default-cover.png؛ همان مقاله در /blogs و در «مقالات مرتبط» هم همان تصویر را داشته باشد.
۳. رندر فیلدهای گمشده
سربرگ (components/blog/head/index.js) — بعد از ردیف نویسنده/تاریخ:
data.reading_time→ «زمان مطالعه: X دقیقه» با همان تایپوگرافیtext-[#9B9B9B].- فهرست کامل تگها به شکل چیپهای لینکدار (اگر صفحهٔ فیلتر تگ ندارد، به
/blogsلینک بده یا بدون لینک رندر کن — تصمیم را در کد کامنت کن).
خلاصه — در Caption.js بالای بدنه، فقط اگر data.summary غیرخالی باشد:
{data?.summary && (
<p className="mt-[16px] text-[#3B3B3B] text-[15px] md:text-[16px] font-medium leading-[30px] border-r-[3px] border-[#E5E5E5] pr-[12px]">
{data.summary}
</p>
)}
FAQ مرئی — components/blog/detail/Faq.js جدید، با <details>/<summary> بومی (بدون وابستگی جدید)، فقط وقتی حداقل یک آیتم معتبر (f.q && f.a) وجود دارد. همان آرایهای که در app/blog/[slug]/page.js به JSON-LD میرود باید اینجا هم رندر شود — تطابق متن مرئی با JSON-LD شرط اعتبار FAQPage است.
منابع — components/blog/detail/Sources.js جدید: data.sources آرایهای از {url, title} است؛ لینکها با target="_blank" rel="nofollow noopener". فقط وقتی آرایه غیرخالی است رندر شود.
هر دو در components/blog/detail/index.js بعد از Caption و قبل از SocialMedia قرار بگیرند.
نحوه تست: یک مقالهٔ واقعی از API بگیر و خروجی را با صفحه مقایسه کن:
curl -s "$NEXT_PUBLIC_API_URL/api/v1/blog/<slug>" | jq '.data.data | {summary,tags,reading_time,faq,sources}'
curl -s "http://yazd-nobat.localhost:3000/blog/<slug>" | grep -c "سؤالات متداول"
سپس مقالهای با faq: [] و sources: [] باز کن → هیچ هدینگ خالی نباشد. JSON-LD صفحه را در Rich Results Test یا با jq اعتبارسنجی کن.
۴. استایل محتوای مقاله + sanitizer
در lib/sanitize.js تگهای figure و figcaption به ALLOWED_TAGS اضافه شوند (خروجی جدول/تصویر CKEditor). هشدار داخل همان فایل را جدی بگیر: این فهرست آینهٔ clinicpro-crawler/content/composer.py است — همانجا هم بهروزرسانی و در گزارش ذکر کن.
در app/globals.css یک کلاس .blog-content تعریف کن که h2/h3/ul/ol/table/img/blockquote/a داخل بدنهٔ مقاله را استایل بدهد (چون class و style عمداً از HTML ورودی حذف میشوند، استایل باید از سمت سایت بیاید)، و در Caption.js روی همان div مربوط به dangerouslySetInnerHTML بنشیند.
نحوه تست: در پنل ادمین یک جدول و یک لیست و یک نقلقول در مقاله درج کن، ذخیره کن، صفحهٔ سایت را ببین: جدول با حاشیه، لیست با bullet، نقلقول با نوار کناری. اسکرینشات ضمیمه شود.
۵. Webhook باطلسازی کش (مصرفکنندهٔ قرارداد بکاند)
app/api/revalidate/route.js جدید:
import { NextResponse } from "next/server";
import { revalidateTag, revalidatePath } from "next/cache";
/**
* باطلسازی on-demand کش ISR بلاگ. بکاند (clinicpro) پس از هر create/update/
* delete/review این مسیر را صدا میزند تا تغییر پنل ادمین بدون تأخیر روی سایت بیاید.
* قرارداد: POST { tags: string[] } + هدر X-Revalidate-Secret
*/
export async function POST(request) {
const secret = process.env.REVALIDATE_SECRET;
if (!secret || request.headers.get("x-revalidate-secret") !== secret) {
return NextResponse.json({ revalidated: false }, { status: 401 });
}
const body = await request.json().catch(() => null);
const tags = body?.tags;
if (!Array.isArray(tags) || tags.length === 0) {
return NextResponse.json({ revalidated: false, error: "tags required" }, { status: 400 });
}
tags.forEach((t) => revalidateTag(String(t)));
if (tags.includes("blog-list")) revalidatePath("/blogs");
return NextResponse.json({ revalidated: true, tags });
}
و در app/blog/[slug]/page.js هر دو tag را ثبت کن تا بکاند بتواند با slug یا uuid باطل کند (URL سایت uuid است، ولی blog-<slug> هم از سمت بکاند میآید):
next: { revalidate: 3600, tags: [`blog-${slug}`, "blog-list"] },
REVALIDATE_SECRET را به .env.example/مستند محیط اضافه کن؛ مقدارش باید با REVALIDATE_WEBHOOK_SECRET سمت clinicpro یکی باشد.
نحوه تست:
npm run build && npm run start
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/api/revalidate # → 401
curl -s -X POST localhost:3000/api/revalidate -H "X-Revalidate-Secret: $REVALIDATE_SECRET" \
-H 'Content-Type: application/json' -d '{"tags":["blog-list"]}' # → {"revalidated":true,...}
curl -s -X POST localhost:3000/api/revalidate -H "X-Revalidate-Secret: $REVALIDATE_SECRET" \
-H 'Content-Type: application/json' -d '{}' -o /dev/null -w '%{http_code}\n' # → 400
سپس تست انتها-به-انتها: عنوان مقاله را در /admin/blogs عوض کن و بلافاصله صفحهٔ سایت را رفرش کن → عنوان جدید (نه ۶۰ دقیقه بعد).
نکات مهم
- ترتیب اجرا: پرامپت
clinicproاول. بدون آن، تست انتها-به-انتهای وظیفهٔ ۵ ممکن نیست (فراخوانندهٔ webhook آنجاست). - بلاگ در بکاند دستهبندی ندارد؛
tagsنقش آن را دارد. اگر کاربر واقعاً Entity دستهبندی میخواهد، تسک جدا با migration لازم است — در این پرامپت نیست و نباید سرخود ساخته شود. components/blogs/Head.jsکد مرده است (return null+ بقیه کامنت). fallbackهایhead-blogs-*.pngداخل آن هیچ اثری ندارند؛ تغییرشان ندهید، فقط در گزارش پایانی ذکر شود.imageUrl()هلپر عمومی است و جاهای دیگر (پزشک، کلینیک) روی پیشفرضuser.pngحساب میکنند؛ امضای آن را عوض نکن —blogCover()جدید فقط برای بلاگ است. (اصل باز/بسته: رفتار جدید با تابع جدید، نه باifروی نوع.)next/imageبرای دامنهٔ API نیاز بهremotePatternsدرnext.configدارد؛ اگر کاور ازNEXT_PUBLIC_API_URLمیآید و بیلد خطا داد، پیکربندی موجود را چک کن (قبل از تغییر،next.config.*را بخوان).- هیچ سکشن خالی رندر نشود — این هم معیار پذیرش مرزی است و هم مسئلهٔ SEO (هدینگ بیمحتوا).
- بعد از پیادهسازی:
npm run lintوnpm run buildباید سبز باشند، و تغییر قرارداد کش را درnobat724_frontREADME/محیط مستند کن.