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:
@@ -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/محیط مستند کن.
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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`);
|
||||
Reference in New Issue
Block a user