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
+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 (