diff --git a/astro.config.mjs b/astro.config.mjs index 99ac264..1de5d1b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -10,6 +10,7 @@ export default defineConfig({ resolve: { alias: { '@': '/src', + '@lib': '/src/lib', '@utils': '/src/lib/utils', '@layouts': '/src/layouts', '@components': '/src/components', diff --git a/src/layouts/MainLayout.astro b/src/layouts/MainLayout.astro index 2aa5b46..ddb78b2 100644 --- a/src/layouts/MainLayout.astro +++ b/src/layouts/MainLayout.astro @@ -29,7 +29,6 @@ import '../styles/global.css';
-
diff --git a/src/lib/api/archiveById.ts b/src/lib/api/archiveById.ts new file mode 100644 index 0000000..5833adf --- /dev/null +++ b/src/lib/api/archiveById.ts @@ -0,0 +1,136 @@ +import { fetchGraphQL } from '@lib/graphql-client.js'; + +//кэширование +import { cache } from '@lib/cache/manager.js'; +import { CACHE_TTL } from '@lib/cache/cache-ttl'; + +type ArchiveType = 'tag' | 'category' | 'author' | 'postType'; + +interface ArchiveParams { + type: ArchiveType; + id?: number; // для tag/category/author + postType?: string; // для CPT + page?: number; + perPage?: number; +} + +/** + * Универсальный helper архивов с cursor-based пагинацией по ID + */ +export async function getArchivePostsById(params: ArchiveParams) { + const page = params.page ?? 1; + const perPage = params.perPage ?? 12; + const after = await getCursorForPage(params, page, perPage); + + const cacheKey = `archive:${params.type}:${params.id || params.postType || 'all'}:${page}:${perPage}`; + + return await cache.wrap( + cacheKey, + async () => { + // Формируем where через переменные + const whereVars: Record = {}; + let whereClause = ''; + + if (params.type === 'tag') { + whereClause = 'tagId: $id'; + whereVars.id = String(params.id); + } else if (params.type === 'category') { + whereClause = 'categoryId: $id'; + whereVars.id = String(params.id); + } else if (params.type === 'author') { + whereClause = 'authorId: $id'; + whereVars.id = String(params.id); + } else if (params.type === 'postType' && params.postType) { + whereClause = '__typename: $postType'; + whereVars.postType = params.postType; + } + + const query = ` + query GetArchivePosts($first: Int!, $after: String${whereVars.id ? ', $id: ID!' : ''}${whereVars.postType ? ', $postType: String!' : ''}) { + profileArticles(first: $first, after: $after, where: { ${whereClause}, orderby: { field: DATE, order: DESC } }) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + databaseId + title + uri + date + featuredImage { node { sourceUrl(size: LARGE) altText } } + author { node { id name firstName lastName avatar { url } uri } } + categories { nodes { id name slug uri databaseId } } + tags { nodes { id name slug uri } } + } + } + } + } + `; + + const variables = { + first: perPage, + after, + ...whereVars + }; + + const data = await fetchGraphQL(query, variables); + const posts = data.profileArticles?.edges?.map((edge: any) => edge.node) || []; + + return { + posts, + pageInfo: data.profileArticles?.pageInfo || { hasNextPage: false, endCursor: null }, + currentPage: page, + perPage, + }; + }, + { ttl: CACHE_TTL.POSTS } + ); +} + +/** + * Получение курсора для страницы N + */ +async function getCursorForPage(params: ArchiveParams, page: number, perPage: number): Promise { + if (page <= 1) return null; + + const cacheKey = `archive-cursor:${params.type}:${params.id || params.postType || 'all'}:${page}:${perPage}`; + + return await cache.wrap(cacheKey, async () => { + const first = perPage * (page - 1); + + const whereVars: Record = {}; + let whereClause = ''; + + if (params.type === 'tag') { + whereClause = 'tagId: $id'; + whereVars.id = String(params.id); + } else if (params.type === 'category') { + whereClause = 'categoryId: $id'; + whereVars.id = String(params.id); + } else if (params.type === 'author') { + whereClause = 'authorId: $id'; + whereVars.id = String(params.id); + } else if (params.type === 'postType' && params.postType) { + whereClause = '__typename: $postType'; + whereVars.postType = params.postType; + } + + const query = ` + query GetCursor($first: Int!${whereVars.id ? ', $id: ID!' : ''}${whereVars.postType ? ', $postType: String!' : ''}) { + profileArticles(first: $first, where: { ${whereClause}, orderby: { field: DATE, order: DESC } }) { + edges { cursor } + } + } + `; + + const variables = { first, ...whereVars }; + const result = await fetchGraphQL(query, variables); + + // используем profileArticles, а не posts + const edges = result.profileArticles?.edges || []; + return edges.at(-1)?.cursor ?? null; + }, { ttl: CACHE_TTL.POSTS }); +} diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index 182405c..1781803 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -1,102 +1,122 @@ import { fetchGraphQL } from './graphql-client.js'; import type { ProfileArticle } from '../types/graphql.js'; +//кэширование +import { cache } from '@lib/cache/manager.js'; +import { CACHE_TTL } from '@lib/cache/cache-ttl'; + export interface AnewsPost { title: string; uri: string; date: string; } -//последние статьи export async function getLatestPosts(first = 12, after = null) { - const query = ` - query GetLatestProfileArticles($first: Int!, $after: String) { - profileArticles( - first: $first, - after: $after, - where: {orderby: { field: DATE, order: DESC }} - ) { - pageInfo { - hasNextPage - endCursor - } - edges { - cursor - node { - id - databaseId - title - uri - date - featuredImage { - node { - sourceUrl(size: LARGE) - altText - } + // Создаем уникальный ключ для кэша + const cacheKey = `latest-posts:${first}:${after || 'first-page'}`; + + return await cache.wrap( + cacheKey, + async () => { + const query = ` + query GetLatestProfileArticles($first: Int!, $after: String) { + profileArticles( + first: $first, + after: $after, + where: {orderby: { field: DATE, order: DESC }} + ) { + pageInfo { + hasNextPage + endCursor } - author { + edges { + cursor node { id - name - firstName - lastName - avatar { - url + databaseId + title + uri + date + featuredImage { + node { + sourceUrl(size: LARGE) + altText + } + } + author { + node { + id + name + firstName + lastName + avatar { + url + } + uri + } + } + categories { + nodes { + id + name + slug + uri + databaseId + } + } + tags { + nodes { + id + name + slug + uri + } } - uri - } - } - categories { - nodes { - id - name - slug - uri - databaseId - } - } - tags { - nodes { - id - name - slug - uri } } } } - } - } - `; + `; - const data = await fetchGraphQL(query, { first, after }); - - // Преобразуем edges в nodes - const posts = data.profileArticles?.edges?.map(edge => edge.node) || []; - - return { - posts, - pageInfo: data.profileArticles?.pageInfo || { hasNextPage: false, endCursor: null } - }; + const data = await fetchGraphQL(query, { first, after }); + + // Преобразуем edges в nodes + const posts = data.profileArticles?.edges?.map(edge => edge.node) || []; + + return { + posts, + pageInfo: data.profileArticles?.pageInfo || { hasNextPage: false, endCursor: null } + }; + }, + { ttl: CACHE_TTL.POSTS } // из конфигурации + ); } -//последние новости +//последние новости (кэшированная версия) export async function getLatestAnews(count = 12): Promise { - const query = ` - query GetAnews($count: Int!) { - aNews(first: $count, where: {orderby: {field: DATE, order: DESC}}) { - nodes { - title - uri - date + const cacheKey = `latest-anews:${count}`; + + return await cache.wrap( + cacheKey, + async () => { + const query = ` + query GetAnews($count: Int!) { + aNews(first: $count, where: {orderby: {field: DATE, order: DESC}}) { + nodes { + title + uri + date + } + } } - } - } - `; + `; - const data = await fetchGraphQL(query, { count }); - return data.aNews?.nodes || []; // Исправлено: aNews вместо anews + const data = await fetchGraphQL(query, { count }); + return data.aNews?.nodes || []; + }, + { ttl: CACHE_TTL.POSTS } + ); } // Получить ProfileArticle по databaseId @@ -182,16 +202,6 @@ export async function getTagBySlug(slug) { slug description count - seo { - title - metaDesc - canonical - opengraphTitle - opengraphDescription - opengraphImage { - sourceUrl - } - } } } `; @@ -208,13 +218,17 @@ export async function getTagBySlug(slug) { /** - * Получить посты тега с пагинацией (offset-based) + * Получить тег с постами по slug с пагинацией */ -export async function getTagPostsPaginated(tagSlug, perPage = 12, page = 1) { +export async function getTagWithPostsPaginated( + tagSlug: string, + perPage: number = 12, + page: number = 1 +) { const offset = (page - 1) * perPage; const query = ` - query GetTagPostsPaginated($slug: ID!, $first: Int!, $offset: Int!) { + query GetTagWithPostsPaginated($slug: ID!, $first: Int!, $offset: Int!) { tag(id: $slug, idType: SLUG) { id databaseId @@ -226,12 +240,20 @@ export async function getTagPostsPaginated(tagSlug, perPage = 12, page = 1) { title metaDesc canonical + opengraphTitle + opengraphDescription + opengraphImage { + sourceUrl + } } posts( first: $first offset: $offset where: { - orderby: { field: DATE, order: DESC } + orderby: { + field: DATE, + order: DESC + } } ) { nodes { @@ -332,7 +354,7 @@ export async function getTagPostsPaginated(tagSlug, perPage = 12, page = 1) { }; } catch (error) { - console.error('Error fetching tag posts:', error); + console.error('Error fetching tag with posts:', error); return { tag: null, posts: [], @@ -345,6 +367,9 @@ export async function getTagPostsPaginated(tagSlug, perPage = 12, page = 1) { } } + + + /** * Получить общее количество постов для всех тегов */ diff --git a/src/lib/api/tags.ts b/src/lib/api/tags.ts new file mode 100644 index 0000000..7df47b8 --- /dev/null +++ b/src/lib/api/tags.ts @@ -0,0 +1,132 @@ +import { fetchGraphQL } from '@lib/graphql-client.js'; + + +//кэширование +import { cache } from '@lib/cache/manager.js'; +import { CACHE_TTL } from '@lib/cache/cache-ttl'; + +interface Tag { + id: string; + databaseId: number; + name: string; + slug: string; + description: string; + count: number; +} + +//информация о теге +export async function getTag(slug: string): Promise { + + const cacheKey = `tag:${slug}`; + return await cache.wrap( + cacheKey, + async () => { + const query = ` + query GetTag($slug: ID!) { + tag(id: $slug, idType: SLUG) { + id + databaseId + name + slug + description + count + } + } + `; + + const data = await fetchGraphQL(query, { slug }); + return data?.tag || null; + }, + { ttl: CACHE_TTL.TAXONOMY } + ); + +} + + + +export async function getTagWithPostsById(tagId, page = 1, perPage = 10) { + const offset = (page - 1) * perPage; + + const query = ` + query GetTagWithPostsById($id: ID!, $size: Int!, $offset: Int!) { + tag(id: $id, idType: DATABASE_ID) { + id + databaseId + name + slug + description + posts( + where: { + offsetPagination: { + size: $size + offset: $offset + } + } + sort: { field: DATE, order: DESC } + ) { + nodes { + id + databaseId + title + excerpt + uri + date + featuredImage { + node { + sourceUrl(size: MEDIUM) + altText + } + } + categories { + nodes { + name + slug + } + } + } + pageInfo { + offsetPagination { + total + hasMore + hasPrevious + } + } + } + } + } + `; + + const data = await fetchGraphQL(query, { + id: tagId, + size: perPage, + offset: offset + }); + + if (!data?.tag) { + console.warn(`Tag with ID ${tagId} not found`); + return null; + } + + const tag = data.tag; + const posts = tag.posts?.nodes || []; + const pagination = tag.posts?.pageInfo?.offsetPagination || {}; + + return { + tag: { + id: tag.id, + databaseId: tag.databaseId, + name: tag.name, + slug: tag.slug, + description: tag.description + }, + posts, + pagination: { + currentPage: page, + totalPages: Math.ceil((pagination.total || 0) / perPage), + totalPosts: pagination.total || 0, + hasNextPage: pagination.hasMore || false, + hasPrevPage: pagination.hasPrevious || false, + postsPerPage: perPage + } + }; +} \ No newline at end of file diff --git a/src/lib/cache/cache-ttl.ts b/src/lib/cache/cache-ttl.ts new file mode 100644 index 0000000..91b1cf1 --- /dev/null +++ b/src/lib/cache/cache-ttl.ts @@ -0,0 +1,10 @@ +/** + * Конфигурация TTL из .env с fallback значениями + */ +export const CACHE_TTL = { + TAXONOMY: parseInt(import.meta.env.CACHE_TAXONOMY_TTL || '3600'), + POSTS: parseInt(import.meta.env.CACHE_POST_TTL || '1800') +} as const; + +// Для отключения кэша +//export const CACHE_DISABLED = 0; \ No newline at end of file diff --git a/src/lib/cache/manager.ts b/src/lib/cache/manager.ts new file mode 100644 index 0000000..05e20b9 --- /dev/null +++ b/src/lib/cache/manager.ts @@ -0,0 +1,77 @@ +export interface CacheOptions { + ttl: number; // время жизни в секундах, 0 = не кешировать +} + +export interface CacheEntry { + data: T; + expires: number; // timestamp в миллисекундах +} + +export class CacheManager { + private store = new Map>(); + + async wrap( + key: string, + fetchFn: () => Promise, + options: { ttl: number } = { ttl: 300 } + ): Promise { + // Если ttl = 0, пропускаем кеширование + if (options.ttl <= 0) { + //console.log('Cache SKIP (ttl=0):', key); + return await fetchFn(); + } + + const cached = this.store.get(key); + + // Проверяем TTL + if (cached && cached.expires > Date.now()) { + console.log('Cache HIT:', key); + return cached.data; + } + + //console.log('Cache MISS:', key); + const data = await fetchFn(); + + // Сохраняем с TTL + this.store.set(key, { + data, + expires: Date.now() + (options.ttl * 1000) + }); + + return data; + } + + // Дополнительные методы для управления кешем + get(key: string): T | null { + const entry = this.store.get(key); + if (!entry || entry.expires <= Date.now()) { + return null; + } + return entry.data; + } + + set(key: string, value: T, ttl: number): void { + if (ttl <= 0) return; + + this.store.set(key, { + data: value, + expires: Date.now() + (ttl * 1000) + }); + } + + delete(key: string): boolean { + return this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } + + has(key: string): boolean { + const entry = this.store.get(key); + return !!entry && entry.expires > Date.now(); + } +} + +// Экспортируем синглтон для использования +export const cache = new CacheManager(); \ No newline at end of file diff --git a/src/lib/graphql-client.js b/src/lib/graphql-client.js index a365357..8dc2fb3 100644 --- a/src/lib/graphql-client.js +++ b/src/lib/graphql-client.js @@ -14,6 +14,14 @@ export async function fetchGraphQL(query, variables = {}) { const json = await res.json(); if (json.errors) { + + // Выводим полный запрос и переменные для IDE + console.error("GraphQL query failed. Copy this to WPGraphQL IDE:"); + console.log("Query:\n", query); + console.log("Variables:\n", JSON.stringify(variables, null, 2)); + console.error("GraphQL errors:", json.errors); + + console.error("GraphQL error:", json.errors); throw new Error("GraphQL query failed"); } diff --git a/src/lib/utils/slugParser.js b/src/lib/utils/slugParser.js deleted file mode 100644 index 5d7632e..0000000 --- a/src/lib/utils/slugParser.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Функция для разбора slug тегов и авторов - */ - -export interface SlugParseResult { - slug: string; - page: number; -} - -export function slugParse(slug: string | string[]): SlugParseResult { - - // Если ничего нет - if (!slug) return { slug: '', page: 1 }; - - // Если массив - if (Array.isArray(slug)) { - if (slug.length === 0) return { slug: '', page: 1 }; - - // Берем последний элемент - const last = slug[slug.length - 1]; - const num = Number(last); - - // Если последний - положительное целое число - if (Number.isInteger(num) && num > 0) { - // Убираем номер страницы - const slugWithoutPage = slug.slice(0, -1); - return { - slug: slugWithoutPage.join('/'), - page: num - }; - } - - // Весь массив - это slug - return { - slug: slug.join('/'), - page: 1 - }; - } - - // Если строка - if (typeof slug === 'string') { - if (slug === '') return { slug: '', page: 1 }; - - // Делим строку - const parts = slug.split('/').filter(p => p !== ''); - if (parts.length === 0) return { slug: '', page: 1 }; - - // Смотрим на последнюю часть - const last = parts[parts.length - 1]; - const num = Number(last); - - if (Number.isInteger(num) && num > 0) { - // Убираем номер страницы - const slugWithoutPage = parts.slice(0, -1).join('/'); - return { - slug: slugWithoutPage, - page: num - }; - } - - // Вся строка - это slug - return { - slug: slug, - page: 1 - }; - } - - // Если другой тип - return { slug: '', page: 1 }; -} \ No newline at end of file diff --git a/src/pages/tag/[...slug].astro b/src/pages/tag/[...slug].astro index 97caec4..a21b0e2 100644 --- a/src/pages/tag/[...slug].astro +++ b/src/pages/tag/[...slug].astro @@ -1,15 +1,44 @@ --- -import { parseSlug } from '@utils/slugParser'; + +import { slugParse } from '@utils/slugParser'; import SimplePagination from '@components/Pagination.astro'; -import { getTagBySlug, getPostsByTagPaginated } from '@api/posts.js'; + +//api +import { getTag, getTagWithPostsById } from '@api/tags.js'; +import { getArchivePostsById } from '@api/archiveById.ts'; import MainLayout from '@layouts/MainLayout.astro'; export const prerender = false; // динамический роутинг -const { slug: rawSlug } = Astro.params; -// Используем функцию -const { slug: tagSlug, page: currentPage } = parseSlug(rawSlug); +const { slug } = Astro.params; + +// Используем функцию (правильное название) +const { slug: tagSlug, page: currentPage } = slugParse(slug); + +// Если slug пустой - 404 +if (!tagSlug) { + return Astro.redirect('/404'); +} + + +// Получаем данные тега +const tag = await getTag(tagSlug); + +if (!tag) { + return Astro.redirect('/404'); +} + + +const { posts, pageInfo } = await getArchivePostsById({ + type: 'tag', + id: tag.databaseId, // ID тега + page: 2, + perPage: 10 +}); + + +console.log('Данные, полученные от pageInfo:', pageInfo); --- @@ -19,6 +48,101 @@ const { slug: tagSlug, page: currentPage } = parseSlug(rawSlug); description="Информационное агентство Деловой журнал Профиль" > -

Current page:{currentPage}

+
+ +
+

+ Тег: {tag.name} +

+ + {tag.description && ( +
+ )} + +
+ Всего постов: {data.pagination?.totalPosts || 0} +
+
+ + + {data.posts && data.posts.length > 0 ? ( + <> +
+ {data.posts.map(post => ( +
+ {post.featuredImage?.node?.sourceUrl && ( + + {post.featuredImage.node.altText + + )} + +
+

+ + {post.title} + +

+ + {post.excerpt && ( +
+ )} + +
+ + + {post.categories?.nodes?.length > 0 && ( +
+ {post.categories.nodes.slice(0, 2).map(cat => ( + + {cat.name} + + ))} +
+ )} +
+
+
+ ))} +
+ + + {data.pagination && data.pagination.totalPages > 1 && ( + + )} + + ) : ( +
+

Постов пока нет

+

В этом теге еще нет опубликованных статей.

+
+ )} +
+ + + + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 58d611d..acbd9b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,9 @@ "paths": { "@/*": ["src/*"], "@api/*": ["src/lib/api/*"], - "@types/*": ["src/lib/types/*"] + "@types/*": ["src/lib/types/*"], + "@utils/*": ["src/lib/utils/*"], + "@cache/*": ["src/lib/utils/*"] } }, "include": [".astro/types.d.ts", "**/*"],