feat: blog section

This commit is contained in:
2026-02-05 14:54:19 +03:30
parent 5e6c788b54
commit 6dc068076d
18 changed files with 1917 additions and 93 deletions
+235
View File
@@ -0,0 +1,235 @@
/**
* زیرساخت کشف بلاک‌های محتوای بلاگ و تبدیل به JSON.
* مرحله ۱: کشف بلاک‌های شناخته‌شده در HTML (collapse, و بعداً blockquote, code, ...)
* مرحله ۲: استخراج payload به صورت JSON برای هر بلاک
* مرحله ۳: لایه بیرونی بر اساس blockType و payload کامپوننت مناسب را رندر می‌کند.
*
* برای افزودن بلاک جدید: یک تعریف در BLOCK_DEFINITIONS اضافه کنید و در رندر blockType را هندل کنید.
*/
import * as cheerio from "cheerio";
import type { Element } from "domhandler";
// ─── انواع بلاک (برای گسترش بعدی) ───────────────────────────────────────────
/** نام نوع بلاک؛ با افزودن بلاک جدید این یونیون را گسترش دهید. */
export type BlockType = "collapse" | "blockquote" | "code";
/** Payload بلاک collapse (سوالات متداول / accordion). */
export interface CollapseBlockPayload {
title: string;
items: { question: string; answer: string }[];
}
/** Payload بلاک blockquote — بعداً پیاده‌سازی می‌شود. */
// export interface BlockquoteBlockPayload { ... }
/** Payload بلاک code — بعداً پیاده‌سازی می‌شود. */
// export interface CodeBlockPayload { ... }
/** یونیون همهٔ payloadها برای تایپ‌امن رندر. */
export type BlockPayload = CollapseBlockPayload; // | BlockquoteBlockPayload | CodeBlockPayload
// ─── سگمنت خروجی برای لایه رندر ─────────────────────────────────────────────
/** یک سگمنت HTML خام. */
export interface HtmlSegment {
type: "html";
content: string;
}
/** یک بلاک با نوع و payload (JSON) برای رندر با کامپوننت مناسب. */
export interface BlockSegment {
type: "block";
blockType: BlockType;
payload: BlockPayload;
}
/** هر سگمنت محتوا: یا HTML یا یک بلاک با JSON. */
export type ContentSegment = HtmlSegment | BlockSegment;
/** خروجی کشف بلاک: HTML با placeholderها و آرایهٔ سگمنت‌ها (با payload توکار). */
export interface DiscoveredContent {
/** HTML بعد از جایگزینی بلاک‌ها با placeholder (برای ادامهٔ پردازش مثلاً تزریق id سرنویس). */
htmlWithPlaceholders: string;
/** سگمنت‌ها به ترتیب برای رندر؛ هر بلاک شامل blockType و payload است. */
segments: ContentSegment[];
}
// ─── تعریف بلاک‌ها (ثبت برای کشف و استخراج) ────────────────────────────────
export interface BlockDefinition<T extends BlockPayload = BlockPayload> {
blockType: BlockType;
/** سلکتور Cheerio برای پیدا کردن المنت ریشهٔ بلاک. */
selector: string;
/** استخراج payload (JSON) از المنت. */
extract: ($: cheerio.CheerioAPI, el: Element) => T;
}
function extractCollapsePayload(
$: cheerio.CheerioAPI,
el: Element
): CollapseBlockPayload {
const $el = $(el);
const innerHtml = $el.html() ?? "";
const $inner = cheerio.load(innerHtml, null, false);
const title = $inner("h2").first().text().trim();
const items: { question: string; answer: string }[] = [];
$inner("ul").each((_: number, ul: Element) => {
const question = $inner(ul).find("li strong").first().text().trim();
const answer = $inner(ul).nextAll("p").first().text().trim();
if (question || answer) items.push({ question, answer });
});
return { title, items };
}
/**
* ثبت همهٔ بلاک‌های شناخته‌شده.
* برای بلاک‌های جدید (مثلاً blockquote, code) فقط یک مورد جدید به این آرایه اضافه کنید.
*/
export const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
blockType: "collapse",
selector: ".collpase-section",
extract: extractCollapsePayload as BlockDefinition["extract"],
},
// بعداً:
// { blockType: "blockquote", selector: "blockquote.wp-block-quote", extract: extractBlockquotePayload },
// { blockType: "code", selector: "pre.wp-block-code", extract: extractCodePayload },
];
// ─── کشف بلاک و ساخت سگمنت‌ها ─────────────────────────────────────────────
const PLACEHOLDER_REGEX =
/<div data-blog-block-type="([^"]+)" data-blog-block-id="(\d+)"[^>]*><\/div>/g;
function isElement(node: unknown): node is Element {
return typeof node === "object" && node !== null && (node as Element).type === "tag";
}
/**
* ترتیب قرارگیری المنت در سند را با یک مپ نگه می‌دارد.
*/
function buildDocumentOrderMap($: cheerio.CheerioAPI): Map<Element, number> {
const map = new Map<Element, number>();
let index = 0;
$("*")
.toArray()
.filter(isElement)
.forEach((el) => {
map.set(el, index++);
});
return map;
}
/**
* مرحله ۱ و ۲: از روی HTML محتوا بلاک‌های شناخته‌شده را کشف می‌کند،
* به JSON (payload) تبدیل می‌کند، و خروجی را به صورت HTML با placeholder و
* آرایهٔ segments (با payload توکار) به لایه بیرونی می‌دهد.
*/
export function discoverBlocks(html: string): DiscoveredContent {
const $ = cheerio.load(html, null, false);
const orderMap = buildDocumentOrderMap($);
type BlockEntry = {
el: Element;
blockType: BlockType;
blockId: number;
payload: BlockPayload;
};
const entries: BlockEntry[] = [];
const blocksByType: Record<string, BlockPayload[]> = {
collapse: [],
blockquote: [],
code: [],
};
for (const def of BLOCK_DEFINITIONS) {
const elements = $('.collpase-section').toArray();
for (const el of elements) {
const payload = def.extract($, el) as BlockPayload;
const blockId = blocksByType[def.blockType].length;
blocksByType[def.blockType].push(payload);
entries.push({
el,
blockType: def.blockType,
blockId,
payload,
});
}
}
// مرتب‌سازی بر اساس ترتیب در سند (از آخر به اول جایگزین می‌کنیم تا ایندکس‌ها جابجا نشوند)
entries.sort((a, b) => orderMap.get(a.el)! - orderMap.get(b.el)!);
// جایگزینی با placeholder از آخر به اول
for (let i = entries.length - 1; i >= 0; i--) {
const { el, blockType, blockId } = entries[i];
$(el).replaceWith(
`<div data-blog-block-type="${blockType}" data-blog-block-id="${blockId}"></div>`
);
}
const htmlWithPlaceholders = $.html() ?? html;
// ساخت segments: تقسیم با placeholder و قرار دادن payload در هر بلاک
const segments: ContentSegment[] = [];
let lastEnd = 0;
const regex = new RegExp(PLACEHOLDER_REGEX.source, "g");
let m: RegExpExecArray | null;
while ((m = regex.exec(htmlWithPlaceholders)) !== null) {
if (m.index > lastEnd) {
segments.push({
type: "html",
content: htmlWithPlaceholders.slice(lastEnd, m.index),
});
}
const blockType = m[1] as BlockType;
const blockId = parseInt(m[2], 10);
const list = blocksByType[blockType];
if (list && blockId >= 0 && blockId < list.length) {
segments.push({
type: "block",
blockType,
payload: list[blockId],
});
}
lastEnd = regex.lastIndex;
}
if (lastEnd < htmlWithPlaceholders.length) {
segments.push({
type: "html",
content: htmlWithPlaceholders.slice(lastEnd),
});
}
return { htmlWithPlaceholders, segments };
}
/**
* بعد از تزریق id سرنویس‌ها در HTML، محتوای هر سگمنت html را با نسخهٔ به‌روز (شامل idها) جایگزین می‌کند.
* لایهٔ blog-utils بعد از inject کردن idها این تابع را صدا می‌زند.
*/
export function mergeHeadingIdsIntoContentSegments(
contentWithIds: string,
segments: ContentSegment[]
): ContentSegment[] {
const parts: string[] = [];
let last = 0;
let m: RegExpExecArray | null;
const regex = new RegExp(PLACEHOLDER_REGEX.source, "g");
while ((m = regex.exec(contentWithIds)) !== null) {
parts.push(contentWithIds.slice(last, m.index));
last = regex.lastIndex;
}
parts.push(contentWithIds.slice(last));
let j = 0;
return segments.map((seg) => {
if (seg.type === "html") {
return { ...seg, content: parts[j++] ?? seg.content };
}
return seg;
});
}
+96
View File
@@ -0,0 +1,96 @@
import {
discoverBlocks,
mergeHeadingIdsIntoContentSegments,
type ContentSegment,
} from "@/lib/blog-blocks";
export type { ContentSegment } from "@/lib/blog-blocks";
/**
* Creates a URL-safe slug from heading text (strip HTML, normalize, Persian-friendly).
*/
function slugify(text: string): string {
const stripped = text.replace(/<[^>]*>/g, "").trim();
return stripped
.replace(/\s+/g, "-")
.replace(/[^\p{L}\p{N}-]/gu, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "section";
}
export interface TocItem {
id: string;
text: string;
level: number; // 2 = h2, 3 = h3, 4 = h4
}
const HEADING_REGEX = /<(h[234])[^>]*>([\s\S]*?)<\/\1>/gi;
const usedIds = new Map<string, number>();
function ensureUniqueId(baseId: string): string {
const count = usedIds.get(baseId) ?? 0;
usedIds.set(baseId, count + 1);
return count === 0 ? baseId : `${baseId}-${count}`;
}
/**
* پارس محتوا: ابتدا بلاک‌ها کشف و به JSON تبدیل می‌شوند (blog-blocks)،
* سپس id سرنویس‌ها تزریق می‌شود و سگمنت‌های نهایی برای رندر برمی‌گردند.
*/
export function parseContentHeadings(html: string): {
contentWithIds: string;
toc: TocItem[];
segments: ContentSegment[];
} {
const { htmlWithPlaceholders, segments: rawSegments } = discoverBlocks(html);
usedIds.clear();
const toc: TocItem[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const parts: string[] = [];
const regex = new RegExp(HEADING_REGEX.source, "gi");
while ((match = regex.exec(htmlWithPlaceholders)) !== null) {
const [fullMatch, tag, inner] = match;
const level = parseInt(tag.charAt(1), 10) as 2 | 3 | 4;
const text = inner.replace(/<[^>]*>/g, "").trim();
const baseId = slugify(text);
const id = ensureUniqueId(baseId);
toc.push({ id, text, level });
const openTag = fullMatch.slice(0, fullMatch.indexOf(">") + 1);
const hasId = /^\s*<h[234][^>]*\bid\s*=/i.test(openTag);
const newOpenTag = hasId
? openTag
: openTag.replace(/^(<\s*h[234])(\s|>)/i, `$1 id="${id}"$2`);
parts.push(htmlWithPlaceholders.slice(lastIndex, match.index));
parts.push(newOpenTag + inner + `</${tag}>`);
lastIndex = regex.lastIndex;
}
parts.push(htmlWithPlaceholders.slice(lastIndex));
const contentWithIds = parts.join("");
const segments = mergeHeadingIdsIntoContentSegments(
contentWithIds,
rawSegments
);
return { contentWithIds, toc, segments };
}
/** Average reading speed in words per minute (Persian/English mix). */
const WPM = 200;
/**
* Estimates reading time in minutes from HTML content.
*/
export function getReadingTimeMinutes(html: string): number {
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const wordCount = text.split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(wordCount / WPM));
}
+15
View File
@@ -0,0 +1,15 @@
import { format, newDate } from "date-fns-jalali";
import { faIR } from "date-fns-jalali/locale";
/**
* Format a Gregorian date (Date or ISO string) to Shamsi (Jalali) Persian string.
* Example: "۱۴ بهمن ۱۴۰۴"
*/
export function formatDateShamsi(
date: Date | string,
formatStr: string = "d MMMM yyyy"
): string {
const d = typeof date === "string" ? new Date(date) : date;
if (Number.isNaN(d.getTime())) return "";
return format(d, formatStr, { locale: faIR });
}
+145
View File
@@ -0,0 +1,145 @@
const WP_API_BASE = "https://wpa.feep.ir/wp-json/wp/v2";
const FETCH_TIMEOUT_MS = 15_000;
export interface WpMediaSize {
source_url: string;
width?: number;
height?: number;
}
export interface WpFeaturedMedia {
source_url: string;
alt_text?: string;
media_details?: {
width?: number;
height?: number;
sizes?: Record<string, WpMediaSize>;
};
}
export interface WpAuthor {
id: number;
name: string;
slug?: string;
}
export interface WpPost {
id: number;
date: string;
modified?: string;
slug: string;
title: { rendered: string };
content: { rendered: string };
excerpt: { rendered: string };
featured_media: number;
categories: number[];
author?: number;
_embedded?: {
"wp:featuredmedia"?: WpFeaturedMedia[];
author?: WpAuthor[];
};
}
/** Returns thumbnail URL for list/card (medium) or full for single post. */
export function getFeaturedImageUrl(
post: WpPost,
size: "medium" | "medium_large" | "full" = "medium"
): string | null {
const media = post._embedded?.["wp:featuredmedia"]?.[0];
if (!media?.source_url) return null;
const sizes = media.media_details?.sizes;
if (sizes?.[size]?.source_url) return sizes[size].source_url;
if (size !== "full" && sizes?.medium?.source_url) return sizes.medium.source_url;
return media.source_url;
}
export interface WpCategory {
id: number;
name: string;
slug: string;
count: number;
}
export interface GetPostsResult {
posts: WpPost[];
total: number;
totalPages: number;
}
export interface GetPostsOptions {
page?: number;
perPage?: number;
search?: string;
categoryId?: number;
}
async function fetchWithTimeout(
url: string,
options?: RequestInit
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
...options,
signal: controller.signal,
next: { revalidate: 0 },
});
clearTimeout(timeout);
return res;
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
export async function getPosts(
options: GetPostsOptions = {}
): Promise<GetPostsResult> {
const { page = 1, perPage = 9, search, categoryId } = options;
const params = new URLSearchParams();
params.set("page", String(page));
params.set("per_page", String(perPage));
if (search?.trim()) params.set("search", search.trim());
if (categoryId) params.set("categories", String(categoryId));
params.set("_embed", "1");
const url = `${WP_API_BASE}/posts?${params.toString()}`;
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
}
const total = parseInt(res.headers.get("x-wp-total") ?? "0", 10);
const totalPages = parseInt(res.headers.get("x-wp-totalpages") ?? "1", 10);
const posts: WpPost[] = await res.json();
return { posts, total, totalPages };
}
export async function getPostBySlug(slug: string): Promise<WpPost | null> {
const decodedSlug = decodeURIComponent(slug);
const params = new URLSearchParams({ slug: decodedSlug, _embed: "1" });
const url = `${WP_API_BASE}/posts?${params.toString()}`;
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
}
const posts: WpPost[] = await res.json();
return posts.length > 0 ? posts[0] : null;
}
export async function getCategories(): Promise<WpCategory[]> {
const url = `${WP_API_BASE}/categories?per_page=100`;
const res = await fetchWithTimeout(url);
if (!res.ok) {
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
}
const data: WpCategory[] = await res.json();
return data.filter((c) => c.count > 0);
}