feat: blog section
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user