- Added image quality settings and device sizes in next.config.ts for better image optimization. - Updated package dependencies, including cheerio and baseline-browser-mapping. - Improved global styles for blog content images to ensure responsiveness. - Refactored button components for consistent sizing and styling across the application. - Enhanced blog post card layout with new variants and improved author name formatting. - Adjusted hero section and content layout for better visual appeal and user experience.
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
/**
|
|
* زیرساخت کشف بلاکهای محتوای بلاگ و تبدیل به 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" | "pullquote" | "tip" | "code";
|
|
|
|
/** Payload بلاک collapse (سوالات متداول / accordion). */
|
|
export interface CollapseBlockPayload {
|
|
title: string;
|
|
items: { question: string; answer: string }[];
|
|
}
|
|
|
|
/** Payload بلاک blockquote (نقلقول و منبع). content به صورت HTML است و باید با dangerouslySetInnerHTML رندر شود. */
|
|
export interface BlockquoteBlockPayload {
|
|
/** HTML محتوای نقلقول (بدون تگ cite). */
|
|
content: string;
|
|
citation?: string;
|
|
}
|
|
|
|
/** Payload بلاک tip (نکته / اینفو با variant: info, warn, danger). */
|
|
export interface TipBlockPayload {
|
|
variant: "info" | "warn" | "danger";
|
|
/** HTML محتوای داخل بلاک (عنوان + متن). */
|
|
content: string;
|
|
}
|
|
|
|
/** Payload بلاک code؛ بعداً پیادهسازی میشود. */
|
|
// export interface CodeBlockPayload { ... }
|
|
|
|
/** یونیون همهٔ payloadها برای تایپامن رندر. */
|
|
export type BlockPayload =
|
|
| CollapseBlockPayload
|
|
| BlockquoteBlockPayload
|
|
| TipBlockPayload;
|
|
// | 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 };
|
|
}
|
|
|
|
/**
|
|
* استخراج از بلاک آکاردئون وردپرس (core/accordion):
|
|
* div.wp-block-accordion > div.wp-block-accordion-item با
|
|
* .wp-block-accordion-heading__toggle-title (سؤال) و .wp-block-accordion-panel (جواب به صورت HTML).
|
|
*/
|
|
function extractAccordionPayload(
|
|
$: cheerio.CheerioAPI,
|
|
el: Element
|
|
): CollapseBlockPayload {
|
|
const $el = $(el);
|
|
const items: { question: string; answer: string }[] = [];
|
|
|
|
$el.find(".wp-block-accordion-item").each((_: number, itemEl: Element) => {
|
|
const $item = $(itemEl);
|
|
const question = $item.find(".wp-block-accordion-heading__toggle-title").first().text().trim();
|
|
const $panel = $item.find(".wp-block-accordion-panel").first();
|
|
const answer = ($panel.html() ?? $panel.text() ?? "").trim();
|
|
if (question || answer) items.push({ question, answer });
|
|
});
|
|
|
|
return { title: "", items };
|
|
}
|
|
|
|
const FAQ_HEADING_RE = /س[وؤ]الات\s*متداول|^faq$/i;
|
|
|
|
function isFaqHeading(text: string): boolean {
|
|
return FAQ_HEADING_RE.test(text.replace(/<[^>]*>/g, "").trim());
|
|
}
|
|
|
|
function isDescendantOfExcluded(el: Element, excluded: Set<Element>): boolean {
|
|
let parent = el.parent;
|
|
while (parent && isElement(parent)) {
|
|
if (excluded.has(parent)) return true;
|
|
parent = parent.parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function nextElementSibling(node: unknown): Element | null {
|
|
let next = (node as { next?: unknown })?.next;
|
|
while (next) {
|
|
if (isElement(next)) return next;
|
|
next = (next as { next?: unknown }).next;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectH3QuestionPairs(
|
|
$: cheerio.CheerioAPI,
|
|
$container: cheerio.Cheerio<Element>,
|
|
items: { question: string; answer: string }[]
|
|
): void {
|
|
$container.find("> h3").each((_: number, h3: Element) => {
|
|
const question = $(h3).text().trim();
|
|
const answers: string[] = [];
|
|
let next = nextElementSibling(h3);
|
|
while (next) {
|
|
const tag = next.tagName?.toLowerCase();
|
|
if (tag === "h2" || tag === "h3") break;
|
|
if (tag === "p") {
|
|
const html = $(next).html()?.trim();
|
|
if (html) answers.push(html);
|
|
next = nextElementSibling(next);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
const answer = answers.join("");
|
|
if (question || answer) items.push({ question, answer });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* استخراج سوالات متداول از گروه alignwide وردپرس (چیدمان ستونی h3 + p).
|
|
*/
|
|
function extractFaqGroupPayload(
|
|
$: cheerio.CheerioAPI,
|
|
el: Element
|
|
): CollapseBlockPayload | null {
|
|
const $el = $(el);
|
|
const $h2 = $el.children("h2").first();
|
|
if (!$h2.length || !isFaqHeading($h2.text())) return null;
|
|
|
|
const title = $h2.text().trim();
|
|
const items: { question: string; answer: string }[] = [];
|
|
|
|
$el.find(".wp-block-column").each((_: number, col: Element) => {
|
|
const $col = $(col);
|
|
const question = $col.find("h3").first().text().trim();
|
|
const $p = $col.find("p").first();
|
|
const answer = ($p.html() ?? "").trim();
|
|
if (question || answer) items.push({ question, answer });
|
|
});
|
|
|
|
if (!items.length) collectH3QuestionPairs($, $el, items);
|
|
|
|
return items.length ? { title, items } : null;
|
|
}
|
|
|
|
type FaqBlockEntry = {
|
|
el: Element;
|
|
blockType: "collapse";
|
|
blockId: number;
|
|
payload: CollapseBlockPayload;
|
|
/** برای FAQهای بدون wrapper؛ همهٔ نودهای h2 + h3 + p حذف میشوند. */
|
|
removeNodes?: Element[];
|
|
};
|
|
|
|
/**
|
|
* کشف بخشهای «سوالات متداول» که بهصورت h2 + جفتهای h3/p در جریان محتوا آمدهاند.
|
|
*/
|
|
function discoverStandaloneFaqSections(
|
|
$: cheerio.CheerioAPI,
|
|
excluded: Set<Element>
|
|
): FaqBlockEntry[] {
|
|
const results: FaqBlockEntry[] = [];
|
|
|
|
$("h2")
|
|
.toArray()
|
|
.filter(isElement)
|
|
.forEach((h2El) => {
|
|
if (excluded.has(h2El) || isDescendantOfExcluded(h2El, excluded)) return;
|
|
if (!isFaqHeading($(h2El).text())) return;
|
|
if ($(h2El).closest("div.wp-block-group.alignwide").length) return;
|
|
|
|
const title = $(h2El).text().trim();
|
|
const items: { question: string; answer: string }[] = [];
|
|
const removeNodes: Element[] = [h2El];
|
|
|
|
let next = nextElementSibling(h2El);
|
|
while (next) {
|
|
const tag = next.tagName?.toLowerCase();
|
|
if (tag === "h2") break;
|
|
|
|
if (tag === "h3") {
|
|
const question = $(next).text().trim();
|
|
removeNodes.push(next);
|
|
const answers: string[] = [];
|
|
let after = nextElementSibling(next);
|
|
while (after) {
|
|
const afterTag = after.tagName?.toLowerCase();
|
|
if (afterTag === "h2" || afterTag === "h3") break;
|
|
if (afterTag === "p") {
|
|
const html = $(after).html()?.trim();
|
|
removeNodes.push(after);
|
|
if (html) answers.push(html);
|
|
after = nextElementSibling(after);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (question || answers.length) {
|
|
items.push({ question, answer: answers.join("") });
|
|
}
|
|
next = nextElementSibling(removeNodes[removeNodes.length - 1]);
|
|
continue;
|
|
}
|
|
|
|
if (tag === "p" && !$(next).text().trim()) {
|
|
removeNodes.push(next);
|
|
next = nextElementSibling(next);
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (items.length) {
|
|
results.push({
|
|
el: h2El,
|
|
blockType: "collapse",
|
|
blockId: -1,
|
|
payload: { title, items },
|
|
removeNodes,
|
|
});
|
|
}
|
|
});
|
|
|
|
return results;
|
|
}
|
|
|
|
function discoverFaqCollapseBlocks(
|
|
$: cheerio.CheerioAPI,
|
|
excluded: Set<Element>,
|
|
blocksByType: Record<string, BlockPayload[]>
|
|
): FaqBlockEntry[] {
|
|
const faqEntries: FaqBlockEntry[] = [];
|
|
|
|
$("div.wp-block-group.alignwide")
|
|
.toArray()
|
|
.filter(isElement)
|
|
.forEach((el) => {
|
|
if (excluded.has(el) || isDescendantOfExcluded(el, excluded)) return;
|
|
const payload = extractFaqGroupPayload($, el);
|
|
if (!payload) return;
|
|
const blockId = blocksByType.collapse.length;
|
|
blocksByType.collapse.push(payload);
|
|
faqEntries.push({ el, blockType: "collapse", blockId, payload });
|
|
excluded.add(el);
|
|
});
|
|
|
|
for (const entry of discoverStandaloneFaqSections($, excluded)) {
|
|
const blockId = blocksByType.collapse.length;
|
|
blocksByType.collapse.push(entry.payload);
|
|
faqEntries.push({ ...entry, blockId });
|
|
excluded.add(entry.el);
|
|
for (const node of entry.removeNodes ?? []) excluded.add(node);
|
|
}
|
|
|
|
return faqEntries;
|
|
}
|
|
|
|
function extractBlockquotePayload(
|
|
$: cheerio.CheerioAPI,
|
|
el: Element
|
|
): BlockquoteBlockPayload {
|
|
const $el = $(el);
|
|
const citation = $el.find("cite").first().text().trim() || undefined;
|
|
const $body = $el.clone();
|
|
$body.find("cite").remove();
|
|
const content = ($body.html() ?? "").trim();
|
|
return { content, citation };
|
|
}
|
|
|
|
/**
|
|
* استخراج از بلاک pullquote وردپرس: figure.wp-block-pullquote > blockquote > p + cite.
|
|
* همان شکل blockquote (content HTML + citation).
|
|
*/
|
|
function extractPullquotePayload(
|
|
$: cheerio.CheerioAPI,
|
|
el: Element
|
|
): BlockquoteBlockPayload {
|
|
const $el = $(el);
|
|
const $blockquote = $el.find("blockquote").first();
|
|
const citation = $blockquote.find("cite").first().text().trim() || undefined;
|
|
const $body = $blockquote.clone();
|
|
$body.find("cite").remove();
|
|
const content = ($body.html() ?? "").trim();
|
|
return { content, citation };
|
|
}
|
|
|
|
/**
|
|
* استخراج از بلاک tip وردپرس: div.wp-block-group.tip با کلاس tip-info | tip-warn | tip-danger.
|
|
*/
|
|
function extractTipPayload($: cheerio.CheerioAPI, el: Element): TipBlockPayload {
|
|
const $el = $(el);
|
|
const classList = ($el.attr("class") ?? "").split(/\s+/);
|
|
let variant: "info" | "warn" | "danger" = "info";
|
|
if (classList.includes("tip-danger")) variant = "danger";
|
|
else if (classList.includes("tip-warn")) variant = "warn";
|
|
else if (classList.includes("tip-info")) variant = "info";
|
|
const content = ($el.html() ?? "").trim();
|
|
return { variant, content };
|
|
}
|
|
|
|
/**
|
|
* ثبت همهٔ بلاکهای شناختهشده.
|
|
* برای بلاکهای جدید (مثلاً blockquote, code) فقط یک مورد جدید به این آرایه اضافه کنید.
|
|
*/
|
|
export const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
|
{
|
|
blockType: "collapse",
|
|
selector: "div.wp-block-group.collapse-section",
|
|
extract: extractCollapsePayload as BlockDefinition["extract"],
|
|
},
|
|
{
|
|
blockType: "collapse",
|
|
selector: "div.wp-block-accordion",
|
|
extract: extractAccordionPayload as BlockDefinition["extract"],
|
|
},
|
|
{
|
|
blockType: "blockquote",
|
|
selector: "blockquote.wp-block-quote",
|
|
extract: extractBlockquotePayload as BlockDefinition["extract"],
|
|
},
|
|
{
|
|
blockType: "pullquote",
|
|
selector: "figure.wp-block-pullquote",
|
|
extract: extractPullquotePayload as BlockDefinition["extract"],
|
|
},
|
|
{
|
|
blockType: "tip",
|
|
selector: "div.wp-block-group.tip",
|
|
extract: extractTipPayload as BlockDefinition["extract"],
|
|
},
|
|
// { 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;
|
|
removeNodes?: Element[];
|
|
};
|
|
const entries: BlockEntry[] = [];
|
|
const blocksByType: Record<string, BlockPayload[]> = {
|
|
collapse: [],
|
|
blockquote: [],
|
|
pullquote: [],
|
|
tip: [],
|
|
code: [],
|
|
};
|
|
|
|
for (const def of BLOCK_DEFINITIONS) {
|
|
const elements = $(def.selector).toArray().filter(isElement);
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
const excluded = new Set(entries.map((entry) => entry.el));
|
|
entries.push(
|
|
...discoverFaqCollapseBlocks($, excluded, blocksByType)
|
|
);
|
|
|
|
// مرتبسازی بر اساس ترتیب در سند (از آخر به اول جایگزین میکنیم تا ایندکسها جابجا نشوند)
|
|
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, removeNodes } = entries[i];
|
|
const placeholder = `<div data-blog-block-type="${blockType}" data-blog-block-id="${blockId}"></div>`;
|
|
if (removeNodes?.length) {
|
|
$(el).before(placeholder);
|
|
for (const node of removeNodes) $(node).remove();
|
|
} else {
|
|
$(el).replaceWith(placeholder);
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|