/** * زیرساخت کشف بلاک‌های محتوای بلاگ و تبدیل به 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 { 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>/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: [], 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( `
` ); } 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; }); }