feat: blog section
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* زیرساخت کشف بلاکهای محتوای بلاگ و تبدیل به 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user