Files

97 lines
2.9 KiB
TypeScript
Raw Permalink Normal View History

2026-05-11 15:30:57 +03:30
import {
discoverBlocks,
mergeHeadingIdsIntoContentSegments,
type ContentSegment,
} from "@/lib/blog-blocks";
export type { ContentSegment } from "@/lib/blog-blocks";
/**
* Creates a URL-safe slug from heading text (strip HTML, normalize, Persian-friendly).
*/
function slugify(text: string): string {
const stripped = text.replace(/<[^>]*>/g, "").trim();
return stripped
.replace(/\s+/g, "-")
.replace(/[^\p{L}\p{N}-]/gu, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "section";
}
export interface TocItem {
id: string;
text: string;
level: number; // 2 = h2, 3 = h3, 4 = h4
}
const HEADING_REGEX = /<(h[234])[^>]*>([\s\S]*?)<\/\1>/gi;
const usedIds = new Map<string, number>();
function ensureUniqueId(baseId: string): string {
const count = usedIds.get(baseId) ?? 0;
usedIds.set(baseId, count + 1);
return count === 0 ? baseId : `${baseId}-${count}`;
}
/**
* پارس محتوا: ابتدا بلاک‌ها کشف و به JSON تبدیل می‌شوند (blog-blocks)،
* سپس id سرنویس‌ها تزریق می‌شود و سگمنت‌های نهایی برای رندر برمی‌گردند.
*/
export function parseContentHeadings(html: string): {
contentWithIds: string;
toc: TocItem[];
segments: ContentSegment[];
} {
const { htmlWithPlaceholders, segments: rawSegments } = discoverBlocks(html);
usedIds.clear();
const toc: TocItem[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const parts: string[] = [];
const regex = new RegExp(HEADING_REGEX.source, "gi");
while ((match = regex.exec(htmlWithPlaceholders)) !== null) {
const [fullMatch, tag, inner] = match;
const level = parseInt(tag.charAt(1), 10) as 2 | 3 | 4;
const text = inner.replace(/<[^>]*>/g, "").trim();
const baseId = slugify(text);
const id = ensureUniqueId(baseId);
toc.push({ id, text, level });
const openTag = fullMatch.slice(0, fullMatch.indexOf(">") + 1);
const hasId = /^\s*<h[234][^>]*\bid\s*=/i.test(openTag);
const newOpenTag = hasId
? openTag
: openTag.replace(/^(<\s*h[234])(\s|>)/i, `$1 id="${id}"$2`);
parts.push(htmlWithPlaceholders.slice(lastIndex, match.index));
parts.push(newOpenTag + inner + `</${tag}>`);
lastIndex = regex.lastIndex;
}
parts.push(htmlWithPlaceholders.slice(lastIndex));
const contentWithIds = parts.join("");
const segments = mergeHeadingIdsIntoContentSegments(
contentWithIds,
rawSegments
);
return { contentWithIds, toc, segments };
}
/** Average reading speed in words per minute (Persian/English mix). */
const WPM = 200;
/**
* Estimates reading time in minutes from HTML content.
*/
export function getReadingTimeMinutes(html: string): number {
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const wordCount = text.split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(wordCount / WPM));
}