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
+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;
}