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:
+204
-5
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user