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`.
This commit is contained in:
hamed
2026-07-27 19:07:30 +03:30
parent 2f8dec0fb8
commit cea8982e6d
15 changed files with 683 additions and 30 deletions
@@ -0,0 +1,360 @@
# سینک کامل صفحهٔ بلاگ با بک‌اند + تصویر پیش‌فرض برند + باطل‌سازی کش
## پروژه
`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`
```jsx
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
...
{cover && cover.trim() !== "" && (
```
→ مقالهٔ بدون تصویر **هیچ تصویری** ندارد.
`components/blogs/latestArticles/Article.js:8-10`
```jsx
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`
```jsx
const cover = data?.images?.[0]?.url
? imageUrl(data.images[0].url)
: "/assets/images/cover-blog-1.png";
```
`app/blog/[slug]/page.js:16`
```js
const FALLBACK_IMG = "/assets/images/og-image.png";
```
و پیش‌فرضِ خودِ هلپر یک آواتار انسان است (`helper/index.js`):
```js
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
```
یعنی هر جای دیگری که `imageUrl(blog.image_url)` بدون آرگومان دوم صدا زده شود، عکسِ «کاربر» را برای مقاله نشان می‌دهد.
### مشکل ۳ — تأخیر تا یک ساعت در نمایش تغییرات ادمین
`app/blog/[slug]/page.js:21-32`
```js
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` (کل فایل):
```jsx
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` (کل فایل):
```jsx
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` (نرمال‌ساز + هلپر تصویر):
```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` (بخش مرتبط):
```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`) بسازد:
```js
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`:
```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` غیرخالی باشد:
```jsx
{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 بگیر و خروجی را با صفحه مقایسه کن:
```bash
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](https://search.google.com/test/rich-results) یا با `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` جدید:
```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>` هم از سمت بک‌اند می‌آید):
```js
next: { revalidate: 3600, tags: [`blog-${slug}`, "blog-list"] },
```
`REVALIDATE_SECRET` را به `.env.example`/مستند محیط اضافه کن؛ مقدارش باید با `REVALIDATE_WEBHOOK_SECRET` سمت `clinicpro` یکی باشد.
**نحوه تست:**
```bash
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/محیط مستند کن.