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/محیط مستند کن.
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { revalidateTag, revalidatePath } from "next/cache";
/**
* باطل‌سازی on-demand کش ISR بلاگ.
*
* `clinicpro` (App\Blog\Service\BlogCacheInvalidator) بعد از هر
* create/update/delete/review این مسیر را صدا می‌زند تا تغییر پنل ادمین بدون
* انتظار یک‌ساعتهٔ `revalidate: 3600` روی سایت بیاید.
*
* قرارداد: 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((tag) => revalidateTag(String(tag)));
// فهرست مقالات صفحهٔ خودش را با tag نمی‌گیرد، پس مسیرش جدا باطل می‌شود.
if (tags.includes("blog-list")) revalidatePath("/blogs");
return NextResponse.json({ revalidated: true, tags });
}
+8 -9
View File
@@ -11,9 +11,8 @@ import {
findCityById,
} from "@/lib/domainHelpers";
import { safeJsonLd } from "@/lib/sanitize";
import { normalizeBlog, imageUrl } from "@/helper";
import { normalizeBlog, blogCover } from "@/helper";
const FALLBACK_IMG = "/assets/images/og-image.png";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// scope دامنه در کلید cache می‌نشیند: یک پست روی دامنهٔ شهریِ دیگر ۴۰۴ است، پس
@@ -21,7 +20,9 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL;
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 ثبت می‌شود چون بک‌اند با slug و uuid هر دو باطل می‌کند و URL
// سایت uuid است؛ `blog-list` هم برای باطل‌سازی گروهی همهٔ مقاله‌ها.
next: { revalidate: 3600, tags: [`blog-${slug}`, "blog-list"] },
});
// ۴۰۴ یعنی «روی این دامنه وجود ندارد» — پستِ شهرِ دیگر (یا پستِ دامنهٔ اصلی)
// نباید روی دامنهٔ شهری باز شود؛ بک‌اند همان فیلتر لیست را اینجا هم اعمال می‌کند.
@@ -72,11 +73,9 @@ export async function generateMetadata({ params }) {
? 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;
// ترتیب تقدم og_image → image_url → کاور پیش‌فرض برند، همان چیزی که صفحه و
// کارت‌ها نشان می‌دهند؛ یک منبع واحد تا OG با تصویر داخل صفحه فرق نکند.
const image = blogCover(blog);
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛ پست سراسری (یا پستِ
// رکورد ریشهٔ nobat724) به دامنهٔ اصلی، نه دامنهٔ جاری — وگرنه یک محتوا روی چند
@@ -149,7 +148,7 @@ async function Blog({ params }) {
"@type": "Article",
headline: blog.title,
...(articleDescription && { description: articleDescription }),
...(blog.images?.[0]?.url && { image: imageUrl(blog.images[0].url) }),
image: blogCover(blog),
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` },
...(blog.created && {
datePublished: new Date(blog.created * 1000).toISOString(),
+72 -1
View File
@@ -904,4 +904,75 @@ html {
.hover-item-clinic:hover h3 {
color: #f17732;
}
}
/* ── محتوای مقاله (خروجی CKEditor پنل ادمین) ──────────────────────────────
sanitizeHtml عمداً class و style را حذف می‌کند، پس استایل جدول/لیست/تیتر
باید از این‌جا بیاید وگرنه محتوا لخت و بدون ساختار دیده می‌شود. */
.blog-content h2,
.blog-content h3,
.blog-content h4,
.blog-content h5 {
color: #3b3b3b;
font-weight: 700;
margin: 24px 0 12px;
line-height: 1.8;
}
.blog-content h2 { font-size: 20px; }
.blog-content h3 { font-size: 18px; }
.blog-content h4,
.blog-content h5 { font-size: 16px; }
.blog-content p { margin-bottom: 12px; }
.blog-content ul,
.blog-content ol {
margin: 12px 0;
padding-right: 22px;
}
.blog-content ul { list-style: disc; }
.blog-content ol { list-style: decimal; }
.blog-content li { margin-bottom: 6px; }
.blog-content a {
color: #5559ce;
text-decoration: underline;
text-underline-offset: 3px;
}
.blog-content blockquote {
margin: 16px 0;
padding: 12px 16px;
border-right: 3px solid #f17732;
background: #fafafa;
border-radius: 8px;
color: #3b3b3b;
}
.blog-content figure { margin: 16px 0; }
.blog-content figcaption {
margin-top: 8px;
color: #9b9b9b;
font-size: 13px;
text-align: center;
}
.blog-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
/* جدول روی موبایل نباید صفحه را افقی اسکرول کند. */
.blog-content figure.table,
.blog-content .table-wrapper {
overflow-x: auto;
}
.blog-content table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
}
.blog-content :is(th, td) {
border: 1px solid #e5e5e5;
padding: 8px 12px;
text-align: right;
}
.blog-content th {
background: #f5f5f5;
font-weight: 700;
color: #3b3b3b;
}
+18 -12
View File
@@ -1,26 +1,32 @@
import React from "react";
import Image from "next/image";
import { sanitizeHtml } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
import { blogCover } from "@/helper";
function Caption({ data }) {
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
// هیچ مقاله‌ای بدون تصویر نمایش داده نمی‌شود؛ نبودِ تصویر شاخص یعنی کاور برند.
const cover = blogCover(data);
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>
<div className="relative w-full aspect-video rounded-[8px] overflow-hidden">
<Image
src={cover}
alt={data?.title || "blog cover"}
fill
priority
sizes="(max-width: 1024px) 100vw, 68vw"
className="object-cover"
/>
</div>
{data?.summary && (
<p className="mt-[16px] sm:mt-[20px] pr-[12px] border-r-[3px] border-[#E5E5E5] text-[#3B3B3B] text-[14px] md:text-[15px] lg:text-[16px] font-medium leading-[28px] md:leading-[30px]">
{data.summary}
</p>
)}
{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]"
className="blog-content 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) }}
/>
)}
+42
View File
@@ -0,0 +1,42 @@
/**
* سؤالات متداول مقاله — همان آرایه‌ای که در `app/blog/[slug]/page.js` به
* FAQPage JSON-LD می‌رود. حضور مرئیِ همین پرسش/پاسخ‌ها شرط اعتبار آن schema است؛
* بدون آن، rich result رد می‌شود.
*
* از <details>/<summary> بومی استفاده می‌شود تا بدون جاوااسکریپت و بدون
* وابستگی جدید، آکاردئون دسترس‌پذیر داشته باشیم.
*/
function Faq({ data }) {
const items = (Array.isArray(data?.faq) ? data.faq : []).filter(
(f) => f?.q && f?.a
);
if (items.length === 0) return null;
return (
<section className="mt-[16px] sm:mt-[20px] md:mt-[24px]">
<h2 className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold mb-[12px] md:mb-[16px]">
سؤالات متداول
</h2>
<ul className="flex flex-col gap-[8px] md:gap-[12px]">
{items.map((item, idx) => (
<li
key={`${item.q}-${idx}`}
className="rounded-[8px] border border-[#EFEFEF] bg-[#FCFCFC] px-[12px] md:px-[16px] py-[10px] md:py-[12px]"
>
<details>
<summary className="cursor-pointer list-none text-[#3B3B3B] text-[13px] md:text-[15px] font-bold">
{item.q}
</summary>
<p className="mt-[8px] text-[#525252] text-[13px] md:text-[15px] font-normal leading-[26px] md:leading-[30px]">
{item.a}
</p>
</details>
</li>
))}
</ul>
</section>
);
}
export default Faq;
+37
View File
@@ -0,0 +1,37 @@
/**
* منابع مقاله (`sources` در پاسخ API) — سیگنال E-E-A-T که تا این تغییر اصلاً
* رندر نمی‌شد. هر آیتم `{ url, title }` است؛ آیتم بدون url رد می‌شود.
*
* لینک‌ها `nofollow` می‌گیرند: منبع بیرونی تأیید ما را همراه ندارد.
*/
function Sources({ data }) {
const items = (Array.isArray(data?.sources) ? data.sources : []).filter(
(s) => s?.url
);
if (items.length === 0) return null;
return (
<section className="mt-[16px] sm:mt-[20px] md:mt-[24px]">
<h2 className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold mb-[12px] md:mb-[16px]">
منابع
</h2>
<ul className="flex flex-col gap-[6px] md:gap-[8px] list-disc pr-[18px]">
{items.map((item, idx) => (
<li key={`${item.url}-${idx}`} className="text-[#525252] text-[12px] md:text-[14px]">
<a
href={item.url}
target="_blank"
rel="nofollow noopener noreferrer"
className="hover:text-[#3B3B3B] break-all"
>
{item.title || item.url}
</a>
</li>
))}
</ul>
</section>
);
}
export default Sources;
+4
View File
@@ -1,4 +1,6 @@
import Caption from "./Caption";
import Faq from "./Faq";
import Sources from "./Sources";
import SocialMedia from "./SocialMedia";
import CommentUser from "./commentUser";
@@ -6,6 +8,8 @@ function Detail({ data }) {
return (
<div className="w-full lg:w-[68%]">
<Caption data={data} />
<Faq data={data} />
<Sources data={data} />
<SocialMedia />
{/* <CommentUser data={data} loading={false} /> */}
</div>
+28
View File
@@ -4,6 +4,10 @@ import { convertTimestampToJalali } from "@/helper";
function Head({ data, cityName }) {
const firstTag = data?.tag?.[0];
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
// normalizeBlog تگ‌ها را به [{name}] تبدیل می‌کند؛ نامِ خالی حذف می‌شود.
const tags = (Array.isArray(data?.tag) ? data.tag : [])
.map((t) => (typeof t === "string" ? t : t?.name))
.filter(Boolean);
return (
<div>
@@ -66,7 +70,31 @@ function Head({ data, cityName }) {
</p>
</div>
)}
{data?.reading_time > 0 && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
زمان مطالعه:
</p>
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
{data.reading_time} دقیقه
</p>
</div>
)}
</div>
{/* فهرست کامل تگ‌ها — تا این تغییر فقط تگ اول در بردکرامب دیده می‌شد.
صفحهٔ فیلتر تگ وجود ندارد، پس چیپ‌ها بدون لینک رندر می‌شوند. */}
{tags.length > 0 && (
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
{tags.map((tag, idx) => (
<li
key={`${tag}-${idx}`}
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
>
{tag}
</li>
))}
</ul>
)}
</div>
);
}
+2 -4
View File
@@ -2,12 +2,10 @@ import CircularLoading from "@/app/component/loading/Circular";
import CustomLoading from "@/app/component/loading/Custom";
import TextLoading from "@/app/component/loading/Text";
import Link from "next/link";
import { convertTimestampToJalali, imageUrl } from "@/helper";
import { blogCover, convertTimestampToJalali } from "@/helper";
function Item({ data, idx, loading }) {
const cover = data?.images?.[0]?.url
? imageUrl(data.images[0].url)
: "/assets/images/cover-blog-1.png";
const cover = blogCover(data);
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
return (
+2 -4
View File
@@ -2,12 +2,10 @@ import CustomLoading from "@/app/component/loading/Custom";
import TextLoading from "@/app/component/loading/Text";
import Image from "next/image";
import Link from "next/link";
import { convertTimestampToJalali, imageUrl } from "@/helper";
import { blogCover, convertTimestampToJalali } from "@/helper";
function Article({ data }) {
const imageSrc = data.images?.[0]?.url
? imageUrl(data.images[0].url, "/assets/images/cover-blog-1.png")
: "/assets/images/cover-blog-1.png";
const imageSrc = blogCover(data);
const createdDate = data.created ? convertTimestampToJalali(data.created) : "";
return (
+14
View File
@@ -70,6 +70,20 @@ export const normalizeBlog = (blog) => {
// تبدیل مسیر نسبی تصویر (/uploads/...) به URL کامل backend.
// مسیرهای absolute (http...) و asset های محلی (/assets، /default) دست‌نخورده می‌مانند.
// تصویر پیش‌فرض همهٔ مقاله‌ها — لیست، کارت، جزئیات، OG و توییتر همگی همین را
// می‌گیرند تا هیچ مقاله‌ای بدون تصویر (یا با آواتار کاربر) نمایش داده نشود.
export const BLOG_FALLBACK_IMG = "/assets/images/blog-default-cover.png";
/**
* کاور یک مقاله با ترتیب تقدم og_image → image_url → images[0].
* جدا از imageUrl() است چون پیش‌فرض آن آواتار کاربر است و برای بلاگ غلط.
*/
export const blogCover = (blog) =>
imageUrl(
blog?.og_image || blog?.image_url || blog?.images?.[0]?.url,
BLOG_FALLBACK_IMG
);
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
if (!url) return fallback;
if (/^https?:\/\//.test(url)) return url;
+3
View File
@@ -12,6 +12,9 @@ export function sanitizeHtml(html) {
"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",
// خروجی CKEditor پنل ادمین جدول و تصویر را داخل <figure> می‌گذارد؛ بدون
// این دو، wrapper حذف و figcaption (متن زیر تصویر) کاملاً گم می‌شد.
"figure", "figcaption",
],
ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"],
ALLOW_DATA_ATTR: false,
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+60
View File
@@ -0,0 +1,60 @@
/**
* تولید تصویر پیش‌فرض مقاله‌ها — `public/assets/images/blog-default-cover.png`.
*
* یک‌بار اجرا می‌شود و خروجی PNG کامیت می‌گردد؛ اسکریپت می‌ماند تا اگر برند یا
* ابعاد عوض شد، تصویر قابل بازتولید باشد:
*
* node scripts/make-blog-cover.mjs
*
* ابعاد ۱۲۰۰×۶۳۰ استاندارد Open Graph است و با `aspect-video` کارت‌ها هم می‌خواند.
* رنگ‌ها و نشان از `public/nobat724.svg` می‌آیند تا با لوگوی سایت یکی بماند.
*/
import sharp from 'sharp';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const W = 1200;
const H = 630;
const BRAND = '#5559CE'; // stroke نشان در public/nobat724.svg
const ACCENT = '#FF8848'; // پلاس نارنجی همان نشان
const OUT = join(ROOT, 'public/assets/images/blog-default-cover.png');
const mark = readFileSync(join(ROOT, 'public/nobat724.svg'), 'utf8')
.replace(/<\?xml[^>]*\?>/, '')
.replace(/width="\d+"\s+height="\d+"/, 'width="200" height="200"');
const fontPath = join(ROOT, 'public/fonts/vazir/Vazirmatn-RD-FD-Bold.ttf');
const fontData = readFileSync(fontPath).toString('base64');
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs>
<style>
@font-face {
font-family: 'VazirmatnCover';
src: url('data:font/ttf;base64,${fontData}') format('truetype');
}
.brand { font-family: 'VazirmatnCover'; font-size: 68px; fill: #1F2247; }
.sub { font-family: 'VazirmatnCover'; font-size: 32px; fill: #6B6E8F; }
</style>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#F7F8FE"/>
<stop offset="100%" stop-color="#ECEDFB"/>
</linearGradient>
</defs>
<rect width="${W}" height="${H}" fill="url(#bg)"/>
<rect x="0" y="0" width="${W}" height="10" fill="${BRAND}"/>
<rect x="0" y="${H - 10}" width="${W}" height="10" fill="${ACCENT}"/>
<g transform="translate(${W / 2 - 100}, 130)">${mark}</g>
<text class="brand" x="${W / 2}" y="420" text-anchor="middle" direction="rtl">نوبت ۷۲۴</text>
<text class="sub" x="${W / 2}" y="480" text-anchor="middle" direction="rtl">مقالات و اخبار پزشکی</text>
</svg>`;
// density بالا برای متن تیز، سپس resize به ابعاد دقیق OG.
const png = await sharp(Buffer.from(svg), { density: 144 }).resize(W, H).png().toBuffer();
writeFileSync(OUT, png);
const meta = await sharp(png).metadata();
console.log(`${OUT}${meta.width}×${meta.height}, ${(png.length / 1024).toFixed(0)}KB`);