Files
aragrp/lib/blog-blocks.ts
T
2026-02-05 14:54:19 +03:30

236 lines
8.8 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" | "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<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 };
}
/**
* ثبت همهٔ بلاک‌های شناخته‌شده.
* برای بلاک‌های جدید (مثلاً 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 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: [],
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(
`<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;
});
}