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

361 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سینک کامل صفحهٔ بلاگ با بک‌اند + تصویر پیش‌فرض برند + باطل‌سازی کش
## پروژه
`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/محیط مستند کن.