- Added image quality settings and device sizes in next.config.ts for better image optimization. - Updated package dependencies, including cheerio and baseline-browser-mapping. - Improved global styles for blog content images to ensure responsiveness. - Refactored button components for consistent sizing and styling across the application. - Enhanced blog post card layout with new variants and improved author name formatting. - Adjusted hero section and content layout for better visual appeal and user experience.
126 lines
4.0 KiB
TypeScript
126 lines
4.0 KiB
TypeScript
import {
|
|
discoverBlocks,
|
|
mergeHeadingIdsIntoContentSegments,
|
|
type ContentSegment,
|
|
} from "@/lib/blog-blocks";
|
|
import { getFullSizeWpImageUrl } from "@/lib/wp-api";
|
|
|
|
export type { ContentSegment } from "@/lib/blog-blocks";
|
|
|
|
const AUTHOR_NAME_OVERRIDES: Record<string, string> = {
|
|
adm: "احمدرضا بدیعی",
|
|
};
|
|
|
|
/** Maps WordPress author display names to site-facing labels. */
|
|
export function formatAuthorName(name: string | null | undefined): string | null {
|
|
if (!name?.trim()) return null;
|
|
const normalized = name.trim().toLowerCase();
|
|
return AUTHOR_NAME_OVERRIDES[normalized] ?? name.trim();
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
enhanceContentImages(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));
|
|
}
|
|
|
|
/** Use full-size WordPress uploads and drop responsive srcset that picks smaller variants. */
|
|
export function enhanceContentImages(html: string): string {
|
|
return html.replace(/<img\b([^>]*)>/gi, (tag, attrs: string) => {
|
|
let next = attrs
|
|
.replace(/\s+srcset=(["'])[^"']*\1/gi, "")
|
|
.replace(/\s+sizes=(["'])[^"']*\1/gi, "")
|
|
.replace(/\s+src=(["'])([^"']+)\1/gi, (_m, quote: string, src: string) => {
|
|
return ` src=${quote}${getFullSizeWpImageUrl(src)}${quote}`;
|
|
});
|
|
if (!/\bloading=/i.test(next)) next += ' loading="lazy"';
|
|
if (!/\bdecoding=/i.test(next)) next += ' decoding="async"';
|
|
return `<img${next}>`;
|
|
});
|
|
}
|
|
|