Files
nobat724_front/.claude/prompt/blog-frontend-sync-and-default-cover.md
hamed cea8982e6d feat: add script to generate default blog cover image
- 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`.
2026-07-27 19:07:30 +03:30

22 KiB
Raw Permalink Blame History

سینک کامل صفحهٔ بلاگ با بک‌اند + تصویر پیش‌فرض برند + باطل‌سازی کش

پروژه

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.png1200 × 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.jsconst cover = blogCover(data); و شرط {cover && ...} حذف شود (همیشه تصویر هست).
  • components/blogs/latestArticles/Article.js:8-10const imageSrc = blogCover(data);
  • components/blog/relatedContent/Item.js:8-10const cover = blogCover(data);
  • app/blog/[slug]/page.js:16,75-79FALLBACK_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_front README/محیط مستند کن.