/** * زیرساخت کشف بلاک‌های محتوای بلاگ و تبدیل به 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 { 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>/g; function isElement(node: unknown): node is Element { return typeof node === "object" && node !== null && (node as Element).type === "tag"; } /** * ترتیب قرارگیری المنت در سند را با یک مپ نگه می‌دارد. */ function buildDocumentOrderMap($: cheerio.CheerioAPI): Map { const map = new Map(); 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 = { 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( `
` ); } 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; }); }