2026-02-05 14:54:19 +03:30
|
|
|
const WP_API_BASE = "https://wpa.feep.ir/wp-json/wp/v2";
|
|
|
|
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
|
|
|
|
2026-02-19 22:57:49 +03:30
|
|
|
/** کش مقالات وردپرس: یک ساعت. با revalidateTag('blog-posts') از API میتوان کش را پاک کرد. */
|
|
|
|
|
const CACHE_REVALIDATE_SECONDS = 3600;
|
|
|
|
|
const CACHE_TAG_BLOG = "blog-posts";
|
|
|
|
|
|
2026-02-05 14:54:19 +03:30
|
|
|
export interface WpMediaSize {
|
|
|
|
|
source_url: string;
|
|
|
|
|
width?: number;
|
|
|
|
|
height?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WpFeaturedMedia {
|
|
|
|
|
source_url: string;
|
|
|
|
|
alt_text?: string;
|
|
|
|
|
media_details?: {
|
|
|
|
|
width?: number;
|
|
|
|
|
height?: number;
|
|
|
|
|
sizes?: Record<string, WpMediaSize>;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WpAuthor {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
slug?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WpPost {
|
|
|
|
|
id: number;
|
|
|
|
|
date: string;
|
|
|
|
|
modified?: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
title: { rendered: string };
|
|
|
|
|
content: { rendered: string };
|
|
|
|
|
excerpt: { rendered: string };
|
|
|
|
|
featured_media: number;
|
|
|
|
|
categories: number[];
|
|
|
|
|
author?: number;
|
|
|
|
|
_embedded?: {
|
|
|
|
|
"wp:featuredmedia"?: WpFeaturedMedia[];
|
|
|
|
|
author?: WpAuthor[];
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 21:35:35 +03:30
|
|
|
/** 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. */
|
2026-02-05 14:54:19 +03:30
|
|
|
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;
|
2026-06-17 21:35:35 +03:30
|
|
|
if (size === "full") return media.source_url;
|
2026-02-05 14:54:19 +03:30
|
|
|
const sizes = media.media_details?.sizes;
|
|
|
|
|
if (sizes?.[size]?.source_url) return sizes[size].source_url;
|
2026-06-17 21:35:35 +03:30
|
|
|
if (sizes?.medium?.source_url) return sizes.medium.source_url;
|
2026-02-05 14:54:19 +03:30
|
|
|
return media.source_url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WpCategory {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
slug: string;
|
|
|
|
|
count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GetPostsResult {
|
|
|
|
|
posts: WpPost[];
|
|
|
|
|
total: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GetPostsOptions {
|
|
|
|
|
page?: number;
|
|
|
|
|
perPage?: number;
|
|
|
|
|
search?: string;
|
|
|
|
|
categoryId?: number;
|
2026-02-08 20:33:07 +03:30
|
|
|
/** ترتیب بر اساس: date, modified, title, ... در وردپرس. برای اولویت دستی (menu_order) پلاگین/فیلتر در وردپرس لازم است. */
|
|
|
|
|
orderby?: string;
|
|
|
|
|
order?: "asc" | "desc";
|
2026-02-05 14:54:19 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchWithTimeout(
|
|
|
|
|
url: string,
|
|
|
|
|
options?: RequestInit
|
|
|
|
|
): Promise<Response> {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
...options,
|
|
|
|
|
signal: controller.signal,
|
2026-02-19 22:57:49 +03:30
|
|
|
next: {
|
|
|
|
|
revalidate: CACHE_REVALIDATE_SECONDS,
|
|
|
|
|
tags: [CACHE_TAG_BLOG],
|
|
|
|
|
},
|
2026-02-05 14:54:19 +03:30
|
|
|
});
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
return res;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPosts(
|
|
|
|
|
options: GetPostsOptions = {}
|
|
|
|
|
): Promise<GetPostsResult> {
|
2026-02-08 20:33:07 +03:30
|
|
|
const { page = 1, perPage = 9, search, categoryId, orderby, order } = options;
|
2026-02-05 14:54:19 +03:30
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
params.set("page", String(page));
|
|
|
|
|
params.set("per_page", String(perPage));
|
|
|
|
|
if (search?.trim()) params.set("search", search.trim());
|
|
|
|
|
if (categoryId) params.set("categories", String(categoryId));
|
2026-02-08 20:33:07 +03:30
|
|
|
if (orderby) params.set("orderby", orderby);
|
|
|
|
|
if (order) params.set("order", order);
|
2026-02-05 14:54:19 +03:30
|
|
|
params.set("_embed", "1");
|
|
|
|
|
|
|
|
|
|
const url = `${WP_API_BASE}/posts?${params.toString()}`;
|
|
|
|
|
const res = await fetchWithTimeout(url);
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const total = parseInt(res.headers.get("x-wp-total") ?? "0", 10);
|
|
|
|
|
const totalPages = parseInt(res.headers.get("x-wp-totalpages") ?? "1", 10);
|
|
|
|
|
const posts: WpPost[] = await res.json();
|
|
|
|
|
|
|
|
|
|
return { posts, total, totalPages };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 02:08:11 +03:30
|
|
|
/** وردپرس گاهی slug را بهصورت percent-encoded ذخیره میکند؛ مسیر ممکن است چندبار encode شده باشد. */
|
|
|
|
|
export function normalizeBlogSlug(slug: string): string[] {
|
|
|
|
|
const variants: string[] = [];
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
let current = slug.trim();
|
2026-02-05 14:54:19 +03:30
|
|
|
|
2026-07-12 02:08:11 +03:30
|
|
|
for (let i = 0; i < 4 && current; i++) {
|
|
|
|
|
if (!seen.has(current)) {
|
|
|
|
|
seen.add(current);
|
|
|
|
|
variants.push(current);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const decoded = decodeURIComponent(current);
|
|
|
|
|
if (decoded === current) break;
|
|
|
|
|
current = decoded;
|
|
|
|
|
} catch {
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-02-05 14:54:19 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-12 02:08:11 +03:30
|
|
|
return variants;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPostBySlug(slug: string): Promise<WpPost | null> {
|
|
|
|
|
const slugVariants = normalizeBlogSlug(slug);
|
|
|
|
|
|
|
|
|
|
for (const slugVariant of slugVariants) {
|
|
|
|
|
const params = new URLSearchParams({ slug: slugVariant, _embed: "1" });
|
|
|
|
|
const url = `${WP_API_BASE}/posts?${params.toString()}`;
|
|
|
|
|
const res = await fetchWithTimeout(url);
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const posts: WpPost[] = await res.json();
|
|
|
|
|
if (posts.length > 0) return posts[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2026-02-05 14:54:19 +03:30
|
|
|
}
|
|
|
|
|
|
2026-02-05 17:58:28 +03:30
|
|
|
export async function getRelatedPosts(
|
|
|
|
|
postId: number,
|
|
|
|
|
categoryIds: number[],
|
|
|
|
|
limit = 3
|
|
|
|
|
): Promise<WpPost[]> {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
params.set("exclude", String(postId));
|
|
|
|
|
params.set("per_page", String(limit));
|
|
|
|
|
params.set("_embed", "1");
|
|
|
|
|
if (categoryIds.length > 0) {
|
|
|
|
|
params.set("categories", String(categoryIds[0]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = `${WP_API_BASE}/posts?${params.toString()}`;
|
|
|
|
|
const res = await fetchWithTimeout(url);
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const posts: WpPost[] = await res.json();
|
|
|
|
|
return posts;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 14:54:19 +03:30
|
|
|
export async function getCategories(): Promise<WpCategory[]> {
|
|
|
|
|
const url = `${WP_API_BASE}/categories?per_page=100`;
|
|
|
|
|
const res = await fetchWithTimeout(url);
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error(`WP API error: ${res.status} ${res.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data: WpCategory[] = await res.json();
|
|
|
|
|
return data.filter((c) => c.count > 0);
|
|
|
|
|
}
|