Files
mr_decor/lib/blog-blocks.ts
T

337 lines
12 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 };
}
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;
};
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,
});
}
}
// مرتب‌سازی بر اساس ترتیب در سند (از آخر به اول جایگزین می‌کنیم تا ایندکس‌ها جابجا نشوند)
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;
});
}