/** * زیرساخت کشف بلاک‌های محتوای بلاگ و تبدیل به JSON. * مرحله ۱: کشف بلاک‌های شناخته‌شده در HTML (collapse, و بعداً blockquote, code, ...) * مرحله ۲: استخراج payload به صورت JSON برای هر بلاک * مرحله ۳: لایه بیرونی بر اساس blockType و payload کامپوننت مناسب را رندر می‌کند. * * برای افزودن بلاک جدید: یک تعریف در BLOCK_DEFINITIONS اضافه کنید و در رندر blockType را هندل کنید. */ import * as cheerio from "cheerio"; import type { Element } from "domhandler"; import { sanitizeBlockquoteContent } from "@/lib/blockquote-content"; import { detectBlockquoteVariant } from "@/lib/blockquote-variant"; import { detectTableVariant, sanitizeTableCellContent, } from "@/lib/table-content"; // ─── انواع بلاک (برای گسترش بعدی) ─────────────────────────────────────────── /** نام نوع بلاک؛ با افزودن بلاک جدید این یونیون را گسترش دهید. */ export type BlockType = | "collapse" | "blockquote" | "pullquote" | "tip" | "table" | "code"; /** Payload بلاک collapse (سوالات متداول / accordion). */ export interface CollapseBlockPayload { title: string; items: { question: string; answer: string }[]; } /** استایل رنگی نقل‌قول: زرد (accent)، سبز (primary)، طوسی (muted). */ export type BlockquoteVariant = "yellow" | "green" | "gray"; /** Payload بلاک blockquote (نقل‌قول و منبع). content به صورت HTML است و باید با dangerouslySetInnerHTML رندر شود. */ export interface BlockquoteBlockPayload { /** HTML محتوای نقل‌قول (بدون تگ cite). */ content: string; citation?: string; /** استایل رنگی؛ از کلاس وردپرس یا تشخیص خودکار متن. */ variant?: BlockquoteVariant; } /** Payload بلاک tip (نکته / اینفو با variant: info, warn, danger). */ export interface TipBlockPayload { variant: "info" | "warn" | "danger"; /** HTML محتوای داخل بلاک (عنوان + متن). */ content: string; } /** استایل UI جدول: one (primary/سبز) یا two (accent/زرد) — هر دو با چیدمان کلاسیک. */ export type TableVariant = "one" | "two"; /** Payload بلاک table (جدول وردپرس). */ export interface TableBlockPayload { variant: TableVariant; /** HTML محتوای هر ستون در سطر سرستون. */ headers: string[]; /** سطرهای بدنه؛ هر سلول HTML است. */ rows: string[][]; caption?: string; } /** Payload بلاک code؛ بعداً پیاده‌سازی می‌شود. */ // export interface CodeBlockPayload { ... } /** یونیون همهٔ payloadها برای تایپ‌امن رندر. */ export type BlockPayload = | CollapseBlockPayload | BlockquoteBlockPayload | TipBlockPayload | TableBlockPayload; // | 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 }; } 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): 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, 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 ): 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, blocksByType: Record ): 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 parseExplicitBlockquoteVariant( classList: string[] ): BlockquoteVariant | undefined { if ( classList.some( (c) => c === "quote-yellow" || c === "is-style-yellow" ) ) { return "yellow"; } if ( classList.some((c) => c === "quote-green" || c === "is-style-green") ) { return "green"; } if ( classList.some((c) => c === "quote-gray" || c === "is-style-gray") ) { return "gray"; } return undefined; } function resolveBlockquoteVariant( content: string, citation: string | undefined, explicit?: BlockquoteVariant ): BlockquoteVariant { if (explicit) return explicit; return detectBlockquoteVariant(content, citation); } function extractBlockquotePayload( $: cheerio.CheerioAPI, el: Element ): BlockquoteBlockPayload { const $el = $(el); const classList = ($el.attr("class") ?? "").split(/\s+/); const explicitVariant = parseExplicitBlockquoteVariant(classList); const citation = $el.find("cite").first().text().trim() || undefined; const $body = $el.clone(); $body.find("cite").remove(); const content = sanitizeBlockquoteContent($body.html() ?? ""); const variant = resolveBlockquoteVariant(content, citation, explicitVariant); return { content, citation, variant }; } /** * استخراج از بلاک pullquote وردپرس: figure.wp-block-pullquote > blockquote > p + cite. * همان شکل blockquote (content HTML + citation). */ function extractPullquotePayload( $: cheerio.CheerioAPI, el: Element ): BlockquoteBlockPayload { const $el = $(el); const classList = [ ...($el.attr("class") ?? "").split(/\s+/), ...($el.find("blockquote").first().attr("class") ?? "").split(/\s+/), ]; const explicitVariant = parseExplicitBlockquoteVariant(classList); 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 = sanitizeBlockquoteContent($body.html() ?? ""); const variant = resolveBlockquoteVariant(content, citation, explicitVariant); return { content, citation, variant }; } /** * استخراج از بلاک tip وردپرس: div.wp-block-group.tip با کلاس tip-info | tip-warn | tip-danger. */ function extractTablePayload( $: cheerio.CheerioAPI, el: Element ): TableBlockPayload { const $el = $(el); const rawText = $el.text(); const captionRaw = $el.find("figcaption").first().text().trim(); const caption = captionRaw ? sanitizeTableCellContent(captionRaw) : undefined; const $table = $el.find("table").first(); const headers: string[] = []; const rows: string[][] = []; $table.find("thead tr").first().find("th, td").each((_, cell) => { headers.push(sanitizeTableCellContent($(cell).html() ?? "")); }); const bodyRowEls = $table.find("tbody tr").toArray().filter(isElement); const allRowEls = bodyRowEls.length ? bodyRowEls : $table.find("tr").toArray().filter(isElement); let bodyStart = 0; if (!headers.length && allRowEls.length) { $(allRowEls[0]) .find("th, td") .each((_, cell) => { headers.push(sanitizeTableCellContent($(cell).html() ?? "")); }); bodyStart = 1; } for (let i = bodyStart; i < allRowEls.length; i++) { const row: string[] = []; $(allRowEls[i]) .find("th, td") .each((_, cell) => { row.push(sanitizeTableCellContent($(cell).html() ?? "")); }); if (row.some((cell) => cell.replace(/<[^>]*>/g, "").trim())) { rows.push(row); } } const variant = detectTableVariant({ rawText, caption, headers, rows }); return { variant, headers, rows, ...(caption ? { caption } : {}) }; } 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: "table", selector: "figure.wp-block-table", extract: extractTablePayload 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; removeNodes?: Element[]; }; const entries: BlockEntry[] = []; const blocksByType: Record = { collapse: [], blockquote: [], pullquote: [], tip: [], table: [], 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 = `
`; 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; }); }