feat: enhance image handling and layout adjustments

- 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.
This commit is contained in:
alirez.br
2026-06-17 21:35:35 +03:30
parent a9ee6659cc
commit ff2632f8db
47 changed files with 1050 additions and 648 deletions
+204 -5
View File
@@ -34,7 +34,7 @@ export interface TipBlockPayload {
content: string;
}
/** Payload بلاک code — بعداً پیاده‌سازی می‌شود. */
/** Payload بلاک code؛ بعداً پیاده‌سازی می‌شود. */
// export interface CodeBlockPayload { ... }
/** یونیون همهٔ payloadها برای تایپ‌امن رندر. */
@@ -122,6 +122,195 @@ function extractAccordionPayload(
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<Element>): 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<Element>,
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<Element>
): 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<Element>,
blocksByType: Record<string, BlockPayload[]>
): 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 extractBlockquotePayload(
$: cheerio.CheerioAPI,
el: Element
@@ -236,6 +425,7 @@ export function discoverBlocks(html: string): DiscoveredContent {
blockType: BlockType;
blockId: number;
payload: BlockPayload;
removeNodes?: Element[];
};
const entries: BlockEntry[] = [];
const blocksByType: Record<string, BlockPayload[]> = {
@@ -261,15 +451,24 @@ export function discoverBlocks(html: string): DiscoveredContent {
}
}
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 } = entries[i];
$(el).replaceWith(
`<div data-blog-block-type="${blockType}" data-blog-block-id="${blockId}"></div>`
);
const { el, blockType, blockId, removeNodes } = entries[i];
const placeholder = `<div data-blog-block-type="${blockType}" data-blog-block-id="${blockId}"></div>`;
if (removeNodes?.length) {
$(el).before(placeholder);
for (const node of removeNodes) $(node).remove();
} else {
$(el).replaceWith(placeholder);
}
}
const htmlWithPlaceholders = $.html() ?? html;
+30 -1
View File
@@ -3,9 +3,21 @@ import {
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).
*/
@@ -43,7 +55,9 @@ export function parseContentHeadings(html: string): {
toc: TocItem[];
segments: ContentSegment[];
} {
const { htmlWithPlaceholders, segments: rawSegments } = discoverBlocks(html);
const { htmlWithPlaceholders, segments: rawSegments } = discoverBlocks(
enhanceContentImages(html)
);
usedIds.clear();
const toc: TocItem[] = [];
@@ -94,3 +108,18 @@ export function getReadingTimeMinutes(html: string): number {
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}>`;
});
}
+8 -2
View File
@@ -40,16 +40,22 @@ export interface WpPost {
};
}
/** Returns thumbnail URL for list/card (medium) or full for single post. */
/** WordPress scaled filenames: photo-1024x768.jpg → photo.jpg */
export function getFullSizeWpImageUrl(url: string): string {
return url.replace(/-\d+x\d+(?=\.(jpe?g|png|gif|webp|avif)(\?|$))/i, "");
}
/** Returns thumbnail URL for list/card (medium) or full original for single post. */
export function getFeaturedImageUrl(
post: WpPost,
size: "medium" | "medium_large" | "full" = "medium"
): string | null {
const media = post._embedded?.["wp:featuredmedia"]?.[0];
if (!media?.source_url) return null;
if (size === "full") return media.source_url;
const sizes = media.media_details?.sizes;
if (sizes?.[size]?.source_url) return sizes[size].source_url;
if (size !== "full" && sizes?.medium?.source_url) return sizes.medium.source_url;
if (sizes?.medium?.source_url) return sizes.medium.source_url;
return media.source_url;
}