aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-24 20:00:08 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-27 14:47:51 +0100
commitf111685c5886f3e77edfd3621c98d8ac1b9bcce4 (patch)
tree62a541fe3afeb64bf745443706fbfb02e96c5230
parentbee515641cb144be9a855ff2cac258d2fedab21d (diff)
refactor(services, types): reorganize GraphQL fetchers and data types
The Typescript mapped types was useful for autocompletion in fetchers but their are harder to maintain. I think it's better to keep each query close to its fetcher to have a better understanding of the fetched data. So I: * colocate queries with their own fetcher * colocate mutations with their own mutator * remove Typescript mapped types for queries and mutations * move data convertors inside graphql services * rename most of data types and fetchers
-rw-r--r--jest.setup.js10
-rw-r--r--src/components/templates/page/page-comments.tsx10
-rw-r--r--src/pages/404.tsx37
-rw-r--r--src/pages/article/[slug].tsx39
-rw-r--r--src/pages/blog/index.tsx69
-rw-r--r--src/pages/blog/page/[number].tsx77
-rw-r--r--src/pages/index.tsx17
-rw-r--r--src/pages/projets/[slug].tsx4
-rw-r--r--src/pages/projets/index.tsx4
-rw-r--r--src/pages/recherche/index.tsx57
-rw-r--r--src/pages/sujet/[slug].tsx20
-rw-r--r--src/pages/thematique/[slug].tsx22
-rw-r--r--src/services/graphql/api.ts83
-rw-r--r--src/services/graphql/articles.query.ts191
-rw-r--r--src/services/graphql/articles.ts201
-rw-r--r--src/services/graphql/comments.mutation.ts30
-rw-r--r--src/services/graphql/comments.query.ts32
-rw-r--r--src/services/graphql/comments.ts177
-rw-r--r--src/services/graphql/contact.mutation.ts25
-rw-r--r--src/services/graphql/contact.ts26
-rw-r--r--src/services/graphql/fetchers/comments/fetch-comments.ts65
-rw-r--r--src/services/graphql/fetchers/comments/index.ts1
-rw-r--r--src/services/graphql/fetchers/index.ts4
-rw-r--r--src/services/graphql/fetchers/posts/fetch-all-posts-slugs.ts34
-rw-r--r--src/services/graphql/fetchers/posts/fetch-last-post-cursor.ts37
-rw-r--r--src/services/graphql/fetchers/posts/fetch-post.ts92
-rw-r--r--src/services/graphql/fetchers/posts/fetch-posts-count.ts43
-rw-r--r--src/services/graphql/fetchers/posts/fetch-posts-list.ts97
-rw-r--r--src/services/graphql/fetchers/posts/fetch-recent-posts.ts76
-rw-r--r--src/services/graphql/fetchers/posts/index.ts6
-rw-r--r--src/services/graphql/fetchers/thematics/fetch-all-thematics-slugs.ts34
-rw-r--r--src/services/graphql/fetchers/thematics/fetch-thematic.ts96
-rw-r--r--src/services/graphql/fetchers/thematics/fetch-thematics-count.ts43
-rw-r--r--src/services/graphql/fetchers/thematics/fetch-thematics-list.ts78
-rw-r--r--src/services/graphql/fetchers/thematics/index.ts4
-rw-r--r--src/services/graphql/fetchers/topics/fetch-all-topics-slugs.ts34
-rw-r--r--src/services/graphql/fetchers/topics/fetch-topic.ts97
-rw-r--r--src/services/graphql/fetchers/topics/fetch-topics-count.ts43
-rw-r--r--src/services/graphql/fetchers/topics/fetch-topics-list.ts84
-rw-r--r--src/services/graphql/fetchers/topics/index.ts4
-rw-r--r--src/services/graphql/helpers/build-comments-tree.test.ts67
-rw-r--r--src/services/graphql/helpers/build-comments-tree.ts30
-rw-r--r--src/services/graphql/helpers/convert-post-preview-to-article-preview.test.ts130
-rw-r--r--src/services/graphql/helpers/convert-post-preview-to-article-preview.ts36
-rw-r--r--src/services/graphql/helpers/convert-post-to-article.test.ts125
-rw-r--r--src/services/graphql/helpers/convert-post-to-article.ts43
-rw-r--r--src/services/graphql/helpers/convert-recent-post-to-recent-article.test.ts48
-rw-r--r--src/services/graphql/helpers/convert-recent-post-to-recent-article.ts24
-rw-r--r--src/services/graphql/helpers/convert-taxonomy-to-page-link.test.ts52
-rw-r--r--src/services/graphql/helpers/convert-taxonomy-to-page-link.ts23
-rw-r--r--src/services/graphql/helpers/convert-wp-comment-to-comment.test.ts93
-rw-r--r--src/services/graphql/helpers/convert-wp-comment-to-comment.ts35
-rw-r--r--src/services/graphql/helpers/convert-wp-image-to-img.test.ts41
-rw-r--r--src/services/graphql/helpers/convert-wp-image-to-img.ts16
-rw-r--r--src/services/graphql/helpers/index.ts7
-rw-r--r--src/services/graphql/index.ts15
-rw-r--r--src/services/graphql/mutators/create-comment.ts70
-rw-r--r--src/services/graphql/mutators/index.ts2
-rw-r--r--src/services/graphql/mutators/send-email.ts49
-rw-r--r--src/services/graphql/thematics.query.ts125
-rw-r--r--src/services/graphql/thematics.ts157
-rw-r--r--src/services/graphql/topics.query.ts137
-rw-r--r--src/services/graphql/topics.ts154
-rw-r--r--src/types/app.ts102
-rw-r--r--src/types/data.ts289
-rw-r--r--src/types/gql.ts73
-rw-r--r--src/types/graphql/generics.ts25
-rw-r--r--src/types/graphql/index.ts3
-rw-r--r--src/types/graphql/mutations.ts60
-rw-r--r--src/types/graphql/queries.ts143
-rw-r--r--src/types/index.ts5
-rw-r--r--src/types/mdx.ts22
-rw-r--r--src/types/raw-data.ts111
-rw-r--r--src/utils/helpers/author.ts31
-rw-r--r--src/utils/helpers/graphql.ts64
-rw-r--r--src/utils/helpers/images.ts17
-rw-r--r--src/utils/helpers/index.ts3
-rw-r--r--src/utils/helpers/pages.tsx83
-rw-r--r--src/utils/helpers/rss.ts25
-rw-r--r--src/utils/helpers/server/projects.ts38
-rw-r--r--src/utils/hooks/use-article.ts28
-rw-r--r--src/utils/hooks/use-comments.ts21
-rw-r--r--src/utils/hooks/use-pagination/use-pagination.test.ts9
-rw-r--r--src/utils/hooks/use-pagination/use-pagination.ts17
-rw-r--r--src/utils/hooks/use-posts-list/use-posts-list.test.ts4
-rw-r--r--src/utils/hooks/use-posts-list/use-posts-list.ts50
-rw-r--r--tests/utils/graphql/connections.ts15
87 files changed, 2600 insertions, 2220 deletions
diff --git a/jest.setup.js b/jest.setup.js
index 8124620..92c6c3b 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -5,3 +5,13 @@ import './tests/jest/__mocks__/matchMedia.mock';
jest.mock('next/router', () => nextRouterMock);
jest.mock('next/dynamic', () => () => 'dynamic-import');
+
+/* Jest complains about "Must use import to load ES Module" when importing
+ * unified and rehype modules. Maybe it is not the right way to avoid those
+ * errors but for now it is the only things that work. */
+jest.mock('src/utils/helpers/rehype.ts', () => {
+ return {
+ __esModule: true,
+ updateContentTree: jest.fn((str) => str),
+ };
+});
diff --git a/src/components/templates/page/page-comments.tsx b/src/components/templates/page/page-comments.tsx
index 170d6b7..5f5208f 100644
--- a/src/components/templates/page/page-comments.tsx
+++ b/src/components/templates/page/page-comments.tsx
@@ -6,8 +6,10 @@ import {
useCallback,
} from 'react';
import { useIntl } from 'react-intl';
-import { sendComment } from '../../../services/graphql';
-import type { SendCommentInput } from '../../../types';
+import {
+ createComment,
+ type CreateCommentInput,
+} from '../../../services/graphql';
import { Heading, Link, Section } from '../../atoms';
import { Card, CardBody } from '../../molecules';
import {
@@ -99,7 +101,7 @@ const PageCommentsWithRef: ForwardRefRenderFunction<
const saveComment: CommentFormSubmit = useCallback(
async (data) => {
- const commentData: SendCommentInput = {
+ const commentData: CreateCommentInput = {
author: data.author,
authorEmail: data.email,
authorUrl: data.website ?? '',
@@ -108,7 +110,7 @@ const PageCommentsWithRef: ForwardRefRenderFunction<
content: data.comment,
parent: data.parentId,
};
- const { comment, success } = await sendComment(commentData);
+ const { comment, success } = await createComment(commentData);
const successPrefix = intl.formatMessage({
defaultMessage: 'Thanks, your comment was successfully sent.',
description: 'PageComments: comment form success message',
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index d6785b6..5f4f89d 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -18,25 +18,26 @@ import {
type SearchFormSubmit,
} from '../components';
import {
- getThematicsPreview,
- getTopicsPreview,
- getTotalThematics,
- getTotalTopics,
+ convertTaxonomyToPageLink,
+ fetchThematicsCount,
+ fetchThematicsList,
+ fetchTopicsCount,
+ fetchTopicsList,
} from '../services/graphql';
import type {
NextPageWithLayout,
- RawThematicPreview,
- RawTopicPreview,
+ WPThematicPreview,
+ WPTopicPreview,
} from '../types';
import { CONFIG } from '../utils/config';
import { ROUTES } from '../utils/constants';
-import { getLinksItemData, getPageLinkFromRawData } from '../utils/helpers';
+import { getLinksItemData } from '../utils/helpers';
import { loadTranslation, type Messages } from '../utils/helpers/server';
import { useBreadcrumb } from '../utils/hooks';
type Error404PageProps = {
- thematicsList: RawThematicPreview[];
- topicsList: RawTopicPreview[];
+ thematicsList: WPThematicPreview[];
+ topicsList: WPTopicPreview[];
translation: Messages;
};
@@ -146,11 +147,7 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
{thematicsListTitle}
</Heading>
}
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
+ items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))}
/>
<LinksWidget
heading={
@@ -158,9 +155,7 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
{topicsListTitle}
</Heading>
}
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
+ items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))}
/>
</PageSidebar>
</Page>
@@ -172,10 +167,10 @@ Error404Page.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<Error404PageProps> = async ({
locale,
}) => {
- const totalThematics = await getTotalThematics();
- const thematics = await getThematicsPreview({ first: totalThematics });
- const totalTopics = await getTotalTopics();
- const topics = await getTopicsPreview({ first: totalTopics });
+ const totalThematics = await fetchThematicsCount();
+ const thematics = await fetchThematicsList({ first: totalThematics });
+ const totalTopics = await fetchTopicsCount();
+ const topics = await fetchTopicsList({ first: totalTopics });
const translation = await loadTranslation(locale);
return {
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 224b1c5..f228ff0 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -21,9 +21,11 @@ import {
TocWidget,
} from '../../components';
import {
- getAllArticlesSlugs,
- getAllComments,
- getArticleBySlug,
+ convertPostToArticle,
+ convertWPCommentToComment,
+ fetchAllPostsSlugs,
+ fetchCommentsList,
+ fetchPost,
} from '../../services/graphql';
import styles from '../../styles/pages/article.module.scss';
import type { Article, NextPageWithLayout, SingleComment } from '../../types';
@@ -63,8 +65,11 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
const intl = useIntl();
const article = useArticle({ slug, fallback: post });
const commentsData = useComments({
- contentId: article?.id,
fallback: comments,
+ first: article?.meta.commentsCount,
+ where: {
+ contentId: article?.id ?? post.id,
+ },
});
const getComments = (data?: SingleComment[]) =>
@@ -73,7 +78,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
author: comment.meta.author,
content: comment.content,
id: comment.id,
- isApproved: comment.approved,
+ isApproved: comment.isApproved,
publicationDate: comment.meta.date,
replies: getComments(comment.replies),
};
@@ -255,7 +260,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
heading={title}
intro={intro}
meta={{
- author: author?.name,
+ author,
publicationDate: dates.publication,
thematics,
updateDate: dates.update,
@@ -292,11 +297,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
]}
/>
</PageSidebar>
- <PageComments
- comments={articleComments ?? []}
- depth={2}
- pageId={id as number}
- />
+ <PageComments comments={articleComments ?? []} depth={2} pageId={id} />
</Page>
);
};
@@ -311,14 +312,20 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
locale,
params,
}) => {
- const post = await getArticleBySlug((params as PostParams).slug);
- const comments = await getAllComments({ contentId: post.id as number });
+ const post = await fetchPost((params as PostParams).slug);
+ const article = await convertPostToArticle(post);
+ const comments = await fetchCommentsList({
+ first: post.commentCount ?? 1,
+ where: { contentId: post.databaseId },
+ });
const translation = await loadTranslation(locale);
return {
props: {
- comments: JSON.parse(JSON.stringify(comments)),
- post: JSON.parse(JSON.stringify(post)),
+ comments: JSON.parse(
+ JSON.stringify(comments.map(convertWPCommentToComment))
+ ),
+ post: JSON.parse(JSON.stringify(article)),
slug: post.slug,
translation,
},
@@ -326,7 +333,7 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
};
export const getStaticPaths: GetStaticPaths = async () => {
- const slugs = await getAllArticlesSlugs();
+ const slugs = await fetchAllPostsSlugs();
const paths = slugs.map((slug) => {
return { params: { slug } };
});
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 0de5523..56cbb02 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -20,27 +20,28 @@ import {
PageSidebar,
} from '../../components';
import {
- getArticles,
- getThematicsPreview,
- getTopicsPreview,
- getTotalArticles,
- getTotalThematics,
- getTotalTopics,
+ convertTaxonomyToPageLink,
+ fetchPostsCount,
+ fetchPostsList,
+ fetchThematicsCount,
+ fetchThematicsList,
+ fetchTopicsCount,
+ fetchTopicsList,
} from '../../services/graphql';
import styles from '../../styles/pages/blog.module.scss';
import type {
- EdgesResponse,
+ GraphQLConnection,
NextPageWithLayout,
- RawArticle,
- RawThematicPreview,
- RawTopicPreview,
+ WPPostPreview,
+ WPThematicPreview,
+ WPTopicPreview,
} from '../../types';
import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
getBlogSchema,
getLinksItemData,
- getPageLinkFromRawData,
+ getPostsWithUrl,
getSchemaJson,
getWebPageSchema,
} from '../../utils/helpers';
@@ -48,9 +49,9 @@ import { loadTranslation, type Messages } from '../../utils/helpers/server';
import { useBreadcrumb, useIsMounted, usePostsList } from '../../utils/hooks';
type BlogPageProps = {
- articles: EdgesResponse<RawArticle>;
- thematicsList: RawThematicPreview[];
- topicsList: RawTopicPreview[];
+ posts: GraphQLConnection<WPPostPreview>;
+ thematicsList: WPThematicPreview[];
+ topicsList: WPTopicPreview[];
totalArticles: number;
translation: Messages;
};
@@ -59,7 +60,7 @@ type BlogPageProps = {
* Blog index page.
*/
const BlogPage: NextPageWithLayout<BlogPageProps> = ({
- articles,
+ posts,
thematicsList,
topicsList,
totalArticles,
@@ -111,6 +112,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
const {
+ articles,
error,
firstNewResultIndex,
isLoading,
@@ -118,10 +120,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
isRefreshing,
hasNextPage,
loadMore,
- posts,
} = usePostsList({
- fallback: [articles],
- fetcher: getArticles,
+ fallback: [posts],
+ fetcher: fetchPostsList,
perPage: CONFIG.postsPerPage,
});
@@ -191,6 +192,10 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
id: 'AXe1Iz',
});
+ const blogArticles = articles?.flatMap((p) =>
+ p.edges.map((edge) => edge.node)
+ );
+
return (
<Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
@@ -218,13 +223,13 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
/>
<PageHeader heading={title} meta={{ total: totalArticles }} />
<PageBody className={styles.body}>
- {posts ? (
+ {blogArticles ? (
<PostsList
className={styles.list}
firstNewResult={firstNewResultIndex}
isLoading={isLoading || isLoadingMore || isRefreshing}
onLoadMore={hasNextPage && isMounted ? loadMore : undefined}
- posts={posts}
+ posts={getPostsWithUrl(blogArticles)}
ref={postsListRef}
sortByYear
total={isMounted ? totalArticles : undefined}
@@ -260,11 +265,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
{thematicsListTitle}
</Heading>
}
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
+ items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))}
/>
<LinksWidget
heading={
@@ -272,9 +273,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
{topicsListTitle}
</Heading>
}
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
+ items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))}
/>
</PageSidebar>
</Page>
@@ -286,17 +285,17 @@ BlogPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
locale,
}) => {
- const articles = await getArticles({ first: CONFIG.postsPerPage });
- const totalArticles = await getTotalArticles();
- const totalThematics = await getTotalThematics();
- const thematics = await getThematicsPreview({ first: totalThematics });
- const totalTopics = await getTotalTopics();
- const topics = await getTopicsPreview({ first: totalTopics });
+ const posts = await fetchPostsList({ first: CONFIG.postsPerPage });
+ const totalArticles = await fetchPostsCount();
+ const totalThematics = await fetchThematicsCount();
+ const thematics = await fetchThematicsList({ first: totalThematics });
+ const totalTopics = await fetchTopicsCount();
+ const topics = await fetchTopicsList({ first: totalTopics });
const translation = await loadTranslation(locale);
return {
props: {
- articles: JSON.parse(JSON.stringify(articles)),
+ posts: JSON.parse(JSON.stringify(posts)),
thematicsList: thematics.edges.map((edge) => edge.node),
topicsList: topics.edges.map((edge) => edge.node),
totalArticles,
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index b254603..d6071d1 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -20,27 +20,28 @@ import {
PageSidebar,
} from '../../../components';
import {
- getArticles,
- getArticlesEndCursor,
- getThematicsPreview,
- getTopicsPreview,
- getTotalArticles,
- getTotalThematics,
- getTotalTopics,
+ convertTaxonomyToPageLink,
+ fetchLastPostCursor,
+ fetchPostsCount,
+ fetchPostsList,
+ fetchThematicsCount,
+ fetchThematicsList,
+ fetchTopicsCount,
+ fetchTopicsList,
} from '../../../services/graphql';
import type {
- EdgesResponse,
+ GraphQLConnection,
NextPageWithLayout,
- RawArticle,
- RawThematicPreview,
- RawTopicPreview,
+ WPPostPreview,
+ WPThematicPreview,
+ WPTopicPreview,
} from '../../../types';
import { CONFIG } from '../../../utils/config';
import { ROUTES } from '../../../utils/constants';
import {
getBlogSchema,
getLinksItemData,
- getPageLinkFromRawData,
+ getPostsWithUrl,
getSchemaJson,
getWebPageSchema,
} from '../../../utils/helpers';
@@ -52,10 +53,10 @@ import {
} from '../../../utils/hooks';
type BlogPageProps = {
- articles: EdgesResponse<RawArticle>;
pageNumber: number;
- thematicsList: RawThematicPreview[];
- topicsList: RawTopicPreview[];
+ posts: GraphQLConnection<WPPostPreview>;
+ thematicsList: WPThematicPreview[];
+ topicsList: WPTopicPreview[];
totalArticles: number;
translation: Messages;
};
@@ -64,8 +65,8 @@ type BlogPageProps = {
* Blog index page.
*/
const BlogPage: NextPageWithLayout<BlogPageProps> = ({
- articles,
pageNumber,
+ posts,
thematicsList,
topicsList,
totalArticles,
@@ -75,9 +76,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
redirectTo: ROUTES.BLOG,
});
- const { posts } = usePostsList({
- fallback: [articles],
- fetcher: getArticles,
+ const { articles } = usePostsList({
+ fallback: [posts],
+ fetcher: fetchPostsList,
perPage: CONFIG.postsPerPage,
});
const intl = useIntl();
@@ -195,6 +196,10 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
id: 'AXe1Iz',
});
+ const blogPageArticles = articles?.flatMap((p) =>
+ p.edges.map((edge) => edge.node)
+ );
+
return (
<Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
@@ -225,7 +230,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
meta={{ total: totalArticles }}
/>
<PageBody>
- <PostsList posts={posts ?? []} sortByYear />
+ <PostsList posts={getPostsWithUrl(blogPageArticles ?? [])} sortByYear />
<Pagination
aria-label={paginationAriaLabel}
current={pageNumber}
@@ -242,11 +247,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
{thematicsListTitle}
</Heading>
}
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
+ items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))}
/>
<LinksWidget
heading={
@@ -254,9 +255,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
{topicsListTitle}
</Heading>
}
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
+ items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))}
/>
</PageSidebar>
</Page>
@@ -274,23 +273,23 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
params,
}) => {
const pageNumber = Number((params as BlogPageParams).number);
- const lastCursor = await getArticlesEndCursor({
- first: CONFIG.postsPerPage * pageNumber,
- });
- const articles = await getArticles({
+ const lastCursor = await fetchLastPostCursor(
+ CONFIG.postsPerPage * pageNumber
+ );
+ const posts = await fetchPostsList({
first: CONFIG.postsPerPage,
after: lastCursor,
});
- const totalArticles = await getTotalArticles();
- const totalThematics = await getTotalThematics();
- const thematics = await getThematicsPreview({ first: totalThematics });
- const totalTopics = await getTotalTopics();
- const topics = await getTopicsPreview({ first: totalTopics });
+ const totalArticles = await fetchPostsCount();
+ const totalThematics = await fetchThematicsCount();
+ const thematics = await fetchThematicsList({ first: totalThematics });
+ const totalTopics = await fetchTopicsCount();
+ const topics = await fetchTopicsList({ first: totalTopics });
const translation = await loadTranslation(locale);
return {
props: {
- articles: JSON.parse(JSON.stringify(articles)),
+ posts: JSON.parse(JSON.stringify(posts)),
pageNumber,
thematicsList: thematics.edges.map((edge) => edge.node),
topicsList: topics.edges.map((edge) => edge.node),
@@ -301,7 +300,7 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
};
export const getStaticPaths: GetStaticPaths = async () => {
- const totalArticles = await getTotalArticles();
+ const totalArticles = await fetchPostsCount();
const totalPages = Math.ceil(totalArticles / CONFIG.postsPerPage);
const pagesArray = Array.from(
{ length: totalPages },
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 56de5b5..7bd8aec 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -26,9 +26,12 @@ import {
} from '../components';
import { mdxComponents } from '../components/mdx';
import HomePageContent from '../content/pages/homepage.mdx';
-import { getArticlesCard } from '../services/graphql';
+import {
+ convertRecentPostToRecentArticle,
+ fetchRecentPosts,
+} from '../services/graphql';
import styles from '../styles/pages/home.module.scss';
-import type { ArticleCard, NextPageWithLayout } from '../types';
+import type { NextPageWithLayout, RecentArticle } from '../types';
import { CONFIG } from '../utils/config';
import { PERSONAL_LINKS, ROUTES } from '../utils/constants';
import { getSchemaJson, getWebPageSchema } from '../utils/helpers';
@@ -229,7 +232,7 @@ const HomePageSection: FC<PageSectionProps> = ({
);
type HomeProps = {
- recentPosts: ArticleCard[];
+ recentPosts: RecentArticle[];
translation?: Messages;
};
@@ -277,7 +280,7 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
hasBorderedValues
isCentered
label={publicationDate}
- value={<Time date={post.dates.publication} />}
+ value={<Time date={post.publicationDate} />}
/>
</CardMeta>
}
@@ -365,11 +368,13 @@ HomePage.getLayout = (page) => getLayout(page, { isHome: true });
export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
const translation = await loadTranslation(locale);
- const recentPosts = await getArticlesCard({ first: 3 });
+ const recentPosts = await fetchRecentPosts({ first: 3 });
return {
props: {
- recentPosts,
+ recentPosts: recentPosts.edges.map((edge) =>
+ convertRecentPostToRecentArticle(edge.node)
+ ),
translation,
},
};
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 2911951..ee88638 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -24,7 +24,7 @@ import {
} from '../../components';
import { mdxComponents } from '../../components/mdx';
import styles from '../../styles/pages/project.module.scss';
-import type { NextPageWithLayout, ProjectPreview, Repos } from '../../types';
+import type { NextPageWithLayout, Project, Repos } from '../../types';
import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
@@ -45,7 +45,7 @@ import {
} from '../../utils/hooks';
type ProjectPageProps = {
- project: ProjectPreview;
+ project: Project;
translation: Messages;
};
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 00c5a70..4e0bf92 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -22,7 +22,7 @@ import {
import { mdxComponents } from '../../components/mdx';
import PageContent, { meta } from '../../content/pages/projects.mdx';
import styles from '../../styles/pages/projects.module.scss';
-import type { NextPageWithLayout, ProjectCard } from '../../types';
+import type { NextPageWithLayout, ProjectPreview } from '../../types';
import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
@@ -38,7 +38,7 @@ import {
import { useBreadcrumb } from '../../utils/hooks';
type ProjectsPageProps = {
- projects: ProjectCard[];
+ projects: ProjectPreview[];
translation?: Messages;
};
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 2a18aa3..293df0e 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -20,25 +20,26 @@ import {
PageBody,
} from '../../components';
import {
- getArticles,
- getThematicsPreview,
- getTopicsPreview,
- getTotalArticles,
- getTotalThematics,
- getTotalTopics,
+ convertTaxonomyToPageLink,
+ fetchPostsCount,
+ fetchPostsList,
+ fetchThematicsCount,
+ fetchThematicsList,
+ fetchTopicsCount,
+ fetchTopicsList,
} from '../../services/graphql';
import styles from '../../styles/pages/blog.module.scss';
import type {
NextPageWithLayout,
- RawThematicPreview,
- RawTopicPreview,
+ WPThematicPreview,
+ WPTopicPreview,
} from '../../types';
import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
getBlogSchema,
getLinksItemData,
- getPageLinkFromRawData,
+ getPostsWithUrl,
getSchemaJson,
getWebPageSchema,
} from '../../utils/helpers';
@@ -46,8 +47,8 @@ import { loadTranslation, type Messages } from '../../utils/helpers/server';
import { useBreadcrumb, useDataFromAPI, usePostsList } from '../../utils/hooks';
type SearchPageProps = {
- thematicsList: RawThematicPreview[];
- topicsList: RawTopicPreview[];
+ thematicsList: WPThematicPreview[];
+ topicsList: WPTopicPreview[];
translation: Messages;
};
@@ -115,6 +116,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
const {
+ articles,
error,
firstNewResultIndex,
isLoading,
@@ -122,16 +124,15 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
isRefreshing,
hasNextPage,
loadMore,
- posts,
} = usePostsList({
fallback: [],
- fetcher: getArticles,
+ fetcher: fetchPostsList,
perPage: CONFIG.postsPerPage,
searchQuery: query.s as string,
});
const totalArticles = useDataFromAPI<number>(async () =>
- getTotalArticles(query.s as string)
+ fetchPostsCount({ search: query.s as string })
);
const thematicsListTitle = intl.formatMessage({
@@ -172,6 +173,10 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
[intl, routerPush]
);
+ const foundArticles = articles?.flatMap((p) =>
+ p.edges.map((edge) => edge.node)
+ );
+
return (
<Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
@@ -199,14 +204,14 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
/>
<PageHeader heading={title} meta={{ total: totalArticles }} />
<PageBody className={styles.body}>
- {posts ? null : <Spinner>{loadingResults}</Spinner>}
- {posts?.length ? (
+ {foundArticles ? null : <Spinner>{loadingResults}</Spinner>}
+ {foundArticles?.length ? (
<PostsList
className={styles.list}
firstNewResult={firstNewResultIndex}
isLoading={isLoading || isLoadingMore || isRefreshing}
onLoadMore={hasNextPage ? loadMore : undefined}
- posts={posts}
+ posts={getPostsWithUrl(foundArticles)}
sortByYear
/>
) : (
@@ -248,11 +253,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
{thematicsListTitle}
</Heading>
}
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
+ items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))}
/>
<LinksWidget
heading={
@@ -260,9 +261,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
{topicsListTitle}
</Heading>
}
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
+ items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))}
/>
</PageSidebar>
</Page>
@@ -274,10 +273,10 @@ SearchPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<SearchPageProps> = async ({
locale,
}) => {
- const totalThematics = await getTotalThematics();
- const thematics = await getThematicsPreview({ first: totalThematics });
- const totalTopics = await getTotalTopics();
- const topics = await getTopicsPreview({ first: totalTopics });
+ const totalThematics = await fetchThematicsCount();
+ const thematics = await fetchThematicsList({ first: totalThematics });
+ const totalTopics = await fetchTopicsCount();
+ const topics = await fetchTopicsList({ first: totalTopics });
const translation = await loadTranslation(locale);
return {
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 30adec3..aed7ea9 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -18,10 +18,11 @@ import {
PageBody,
} from '../../components';
import {
- getAllTopicsSlugs,
- getTopicBySlug,
- getTopicsPreview,
- getTotalTopics,
+ convertTaxonomyToPageLink,
+ fetchAllTopicsSlugs,
+ fetchTopic,
+ fetchTopicsCount,
+ fetchTopicsList,
} from '../../services/graphql';
import styles from '../../styles/pages/blog.module.scss';
import type { NextPageWithLayout, PageLink, Topic } from '../../types';
@@ -29,7 +30,6 @@ import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
getLinksItemData,
- getPageLinkFromRawData,
getPostsWithUrl,
getSchemaJson,
getSinglePageSchema,
@@ -208,13 +208,13 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
locale,
params,
}) => {
- const currentTopic = await getTopicBySlug((params as TopicParams).slug);
- const totalTopics = await getTotalTopics();
- const allTopicsEdges = await getTopicsPreview({
+ const currentTopic = await fetchTopic((params as TopicParams).slug);
+ const totalTopics = await fetchTopicsCount();
+ const allTopicsEdges = await fetchTopicsList({
first: totalTopics,
});
const allTopics = allTopicsEdges.edges.map((edge) =>
- getPageLinkFromRawData(edge.node, 'topic')
+ convertTaxonomyToPageLink(edge.node)
);
const topicsLinks = allTopics.filter(
(topic) => topic.url !== `${ROUTES.TOPICS}/${(params as TopicParams).slug}`
@@ -231,7 +231,7 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
};
export const getStaticPaths: GetStaticPaths = async () => {
- const slugs = await getAllTopicsSlugs();
+ const slugs = await fetchAllTopicsSlugs();
const paths = slugs.map((slug) => {
return { params: { slug } };
});
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index b8518c5..a44c98b 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -17,10 +17,11 @@ import {
PageBody,
} from '../../components';
import {
- getAllThematicsSlugs,
- getThematicBySlug,
- getThematicsPreview,
- getTotalThematics,
+ convertTaxonomyToPageLink,
+ fetchAllThematicsSlugs,
+ fetchThematic,
+ fetchThematicsCount,
+ fetchThematicsList,
} from '../../services/graphql';
import styles from '../../styles/pages/blog.module.scss';
import type { NextPageWithLayout, PageLink, Thematic } from '../../types';
@@ -28,7 +29,6 @@ import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
getLinksItemData,
- getPageLinkFromRawData,
getPostsWithUrl,
getSchemaJson,
getSinglePageSchema,
@@ -191,15 +191,13 @@ export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({
locale,
params,
}) => {
- const currentThematic = await getThematicBySlug(
- (params as ThematicParams).slug
- );
- const totalThematics = await getTotalThematics();
- const allThematicsEdges = await getThematicsPreview({
+ const currentThematic = await fetchThematic((params as ThematicParams).slug);
+ const totalThematics = await fetchThematicsCount();
+ const allThematicsEdges = await fetchThematicsList({
first: totalThematics,
});
const allThematics = allThematicsEdges.edges.map((edge) =>
- getPageLinkFromRawData(edge.node, 'thematic')
+ convertTaxonomyToPageLink(edge.node)
);
const allThematicsLinks = allThematics.filter(
(thematic) =>
@@ -218,7 +216,7 @@ export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({
};
export const getStaticPaths: GetStaticPaths = async () => {
- const slugs = await getAllThematicsSlugs();
+ const slugs = await fetchAllThematicsSlugs();
const paths = slugs.map((slug) => {
return { params: { slug } };
});
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts
deleted file mode 100644
index 003f92d..0000000
--- a/src/services/graphql/api.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import type {
- Mutations,
- MutationsInputMap,
- MutationsResponseMap,
- Queries,
- QueriesInputMap,
- QueriesResponseMap,
-} from '../../types';
-import { CONFIG } from '../../utils/config';
-
-/**
- * Retrieve the API url from settings.
- *
- * @returns {string} The API url.
- */
-export const getAPIUrl = (): string => {
- const { url } = CONFIG.api;
-
- if (!url) {
- throw new Error('API url is not defined.');
- }
-
- return url;
-};
-
-export type ResponseMap<T, K extends Mutations | Queries> = K extends Mutations
- ? MutationsResponseMap<T>
- : QueriesResponseMap<T>;
-
-export type InputMap<T extends Mutations | Queries> = T extends Mutations
- ? MutationsInputMap
- : QueriesInputMap;
-
-type FetchAPIVariables<T> = T extends Queries
- ? QueriesInputMap[T]
- : T extends Mutations
- ? MutationsInputMap[T]
- : never;
-
-type FetchAPIProps<Q extends Queries | Mutations, V = FetchAPIVariables<Q>> = {
- query: Q;
- variables?: V;
-};
-
-type FetchAPIResponse<T, K extends Queries | Mutations> = K extends Queries
- ? QueriesResponseMap<T>[K]
- : K extends Mutations
- ? MutationsResponseMap<T>[K]
- : never;
-
-export const fetchAPI = async <T, K extends Queries | Mutations>({
- query,
- variables,
-}: FetchAPIProps<K>): Promise<FetchAPIResponse<T, K>> => {
- const response = await fetch(getAPIUrl(), {
- method: 'POST',
- headers: {
- 'content-type': 'application/json;charset=UTF-8',
- },
- body: JSON.stringify({
- query,
- variables,
- }),
- });
-
- type JSONResponse = {
- data?: FetchAPIResponse<T, K>;
- errors?: { message: string }[];
- };
-
- const { data, errors }: JSONResponse = await response.json();
-
- if (response.ok) {
- if (!data) return Promise.reject(new Error(`No data found"`));
-
- return data;
- }
- console.error('Failed to fetch API');
- const error = new Error(
- errors?.map((e) => e.message).join('\n') ?? 'unknown'
- );
- return Promise.reject(error);
-};
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
deleted file mode 100644
index 46e3df6..0000000
--- a/src/services/graphql/articles.query.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-/**
- * Query the full article data using its slug.
- */
-export const articleBySlugQuery = `query PostBy($slug: ID!) {
- post(id: $slug, idType: SLUG) {
- acfPosts {
- postsInThematic {
- ... on Thematic {
- databaseId
- slug
- title
- }
- }
- postsInTopic {
- ... on Topic {
- databaseId
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- slug
- title
- }
- }
- }
- author {
- node {
- gravatarUrl
- name
- url
- }
- }
- commentCount
- contentParts {
- afterMore
- beforeMore
- }
- databaseId
- date
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- info {
- wordsCount
- }
- modified
- seo {
- metaDesc
- title
- }
- slug
- title
- }
-}`;
-
-/**
- * Query an array of partial articles.
- */
-export const articlesQuery = `query Articles($after: String = "", $first: Int = 10, $search: String = "") {
- posts(
- after: $after
- first: $first
- where: {orderby: {field: DATE, order: DESC}, search: $search, status: PUBLISH}
- ) {
- edges {
- cursor
- node {
- acfPosts {
- postsInThematic {
- ... on Thematic {
- databaseId
- slug
- title
- }
- }
- }
- commentCount
- contentParts {
- beforeMore
- }
- databaseId
- date
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- info {
- wordsCount
- }
- modified
- slug
- title
- }
- }
- pageInfo {
- endCursor
- hasNextPage
- total
- }
- }
-}`;
-
-/**
- * Query an array of articles with only the minimal data.
- */
-export const articlesCardQuery = `query ArticlesCard($first: Int = 10) {
- posts(
- first: $first
- where: {orderby: {field: DATE, order: DESC}, status: PUBLISH}
- ) {
- nodes {
- databaseId
- date
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- slug
- title
- }
- }
-}`;
-
-/**
- * Query an array of articles slug.
- */
-export const articlesSlugQuery = `query ArticlesSlug($first: Int = 10, $after: String = "") {
- posts(after: $after, first: $first) {
- edges {
- cursor
- node {
- slug
- }
- }
- pageInfo {
- total
- }
- }
-}`;
-
-/**
- * Query the total number of articles.
- */
-export const totalArticlesQuery = `query PostsTotal($search: String = "") {
- posts(where: {search: $search}) {
- pageInfo {
- total
- }
- }
-}`;
-
-/**
- * Query the end cursor based on the queried posts number.
- */
-export const articlesEndCursorQuery = `query EndCursorAfter($first: Int) {
- posts(first: $first) {
- pageInfo {
- hasNextPage
- endCursor
- }
- }
-}`;
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
deleted file mode 100644
index 82bde41..0000000
--- a/src/services/graphql/articles.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import type {
- Article,
- ArticleCard,
- EdgesResponse,
- EndCursorResponse,
- GraphQLEdgesInput,
- GraphQLPageInfo,
- RawArticle,
- RawArticlePreview,
- Slug,
- TotalItems,
-} from '../../types';
-import {
- getAuthorFromRawData,
- getImageFromRawData,
- getPageLinkFromRawData,
- updateContentTree,
-} from '../../utils/helpers';
-import { fetchAPI } from './api';
-import {
- articleBySlugQuery,
- articlesCardQuery,
- articlesEndCursorQuery,
- articlesQuery,
- articlesSlugQuery,
- totalArticlesQuery,
-} from './articles.query';
-
-/**
- * Retrieve the total number of articles.
- *
- * @returns {Promise<number>} - The articles total number.
- */
-export const getTotalArticles = async (search?: string): Promise<number> => {
- const response = await fetchAPI<TotalItems, typeof totalArticlesQuery>({
- query: totalArticlesQuery,
- variables: { search },
- });
-
- return response.posts.pageInfo.total;
-};
-
-export type GetArticlesReturn = {
- articles: Article[];
- pageInfo: GraphQLPageInfo;
-};
-
-/**
- * Convert raw data to an Article object.
- *
- * @param {RawArticle} data - The page raw data.
- * @returns {Article} The page data.
- */
-export const getArticleFromRawData = async (
- data: RawArticle
-): Promise<Article> => {
- const {
- acfPosts,
- author,
- commentCount,
- contentParts,
- databaseId,
- date,
- featuredImage,
- info,
- modified,
- slug,
- title,
- seo,
- } = data;
-
- return {
- content: await updateContentTree(contentParts.afterMore),
- id: databaseId,
- intro: contentParts.beforeMore,
- meta: {
- author: author && getAuthorFromRawData(author.node, 'page'),
- commentsCount: commentCount ?? 0,
- cover: featuredImage?.node
- ? getImageFromRawData(featuredImage.node)
- : undefined,
- dates: { publication: date, update: modified },
- seo: {
- description: seo?.metaDesc ?? '',
- title: seo?.title ?? '',
- },
- thematics: acfPosts.postsInThematic?.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- ),
- topics: acfPosts.postsInTopic?.map((topic) =>
- getPageLinkFromRawData(topic, 'topic')
- ),
- wordsCount: info.wordsCount,
- },
- slug,
- title,
- };
-};
-
-/**
- * Retrieve the given number of articles from API.
- *
- * @param {GraphQLEdgesInput} props - An object of GraphQL variables.
- * @returns {Promise<EdgesResponse<RawArticle>>} The articles data.
- */
-export const getArticles = async (
- props: GraphQLEdgesInput
-): Promise<EdgesResponse<RawArticle>> => {
- const response = await fetchAPI<RawArticle, typeof articlesQuery>({
- query: articlesQuery,
- variables: { ...props },
- });
-
- return response.posts;
-};
-
-/**
- * Convert a raw article preview to an article card.
- *
- * @param {RawArticlePreview} data - A raw article preview.
- * @returns {ArticleCard} An article card.
- */
-const getArticleCardFromRawData = (data: RawArticlePreview): ArticleCard => {
- const { databaseId, date, featuredImage, slug, title } = data;
-
- return {
- cover: featuredImage ? getImageFromRawData(featuredImage.node) : undefined,
- dates: { publication: date },
- id: databaseId,
- slug,
- title,
- };
-};
-
-/**
- * Retrieve the given number of article cards from API.
- *
- * @param {GraphQLEdgesInput} obj - An object.
- * @param {number} obj.first - The number of articles.
- * @returns {Promise<ArticleCard[]>} - The article cards data.
- */
-export const getArticlesCard = async ({
- first,
-}: GraphQLEdgesInput): Promise<ArticleCard[]> => {
- const response = await fetchAPI<RawArticlePreview, typeof articlesCardQuery>({
- query: articlesCardQuery,
- variables: { first },
- });
-
- return response.posts.nodes.map((node) => getArticleCardFromRawData(node));
-};
-
-/**
- * Retrieve an Article object by slug.
- *
- * @param {string} slug - The article slug.
- * @returns {Promise<Article>} The requested article.
- */
-export const getArticleBySlug = async (slug: string): Promise<Article> => {
- const response = await fetchAPI<RawArticle, typeof articleBySlugQuery>({
- query: articleBySlugQuery,
- variables: { slug },
- });
-
- return getArticleFromRawData(response.post);
-};
-
-/**
- * Retrieve all the articles slugs.
- *
- * @returns {Promise<string[]>} - An array of articles slugs.
- */
-export const getAllArticlesSlugs = async (): Promise<string[]> => {
- const totalArticles = await getTotalArticles();
- const response = await fetchAPI<Slug, typeof articlesSlugQuery>({
- query: articlesSlugQuery,
- variables: { first: totalArticles },
- });
-
- return response.posts.edges.map((edge) => edge.node.slug);
-};
-
-/**
- * Retrieve the last cursor.
- *
- * @param {GraphQLEdgesInput} props - An object of GraphQL variables.
- * @returns {Promise<string>} - The end cursor.
- */
-export const getArticlesEndCursor = async (
- props: GraphQLEdgesInput
-): Promise<string> => {
- const response = await fetchAPI<
- EndCursorResponse,
- typeof articlesEndCursorQuery
- >({
- query: articlesEndCursorQuery,
- variables: { ...props },
- });
-
- return response.posts.pageInfo.endCursor;
-};
diff --git a/src/services/graphql/comments.mutation.ts b/src/services/graphql/comments.mutation.ts
deleted file mode 100644
index f52c7e9..0000000
--- a/src/services/graphql/comments.mutation.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Send comment mutation.
- */
-export const sendCommentMutation = `mutation CreateComment(
- $author: String!
- $authorEmail: String!
- $authorUrl: String!
- $content: String!
- $parent: ID = null
- $commentOn: Int!
- $clientMutationId: String!
-) {
- createComment(
- input: {
- author: $author
- authorEmail: $authorEmail
- authorUrl: $authorUrl
- content: $content
- parent: $parent
- commentOn: $commentOn
- clientMutationId: $clientMutationId
- }
- ) {
- clientMutationId
- success
- comment {
- approved
- }
- }
-}`;
diff --git a/src/services/graphql/comments.query.ts b/src/services/graphql/comments.query.ts
deleted file mode 100644
index 5110db3..0000000
--- a/src/services/graphql/comments.query.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Query the comments data by post id.
- */
-export const commentsQuery = `query CommentsByPostId($contentId: ID!, $first: Int = 10, $after: String = "") {
- comments(
- where: {contentId: $contentId}
- first: $first
- after: $after
- ) {
- edges {
- cursor
- node {
- approved
- author {
- node {
- gravatarUrl
- name
- url
- }
- }
- content
- databaseId
- date
- parentDatabaseId
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
-}`;
diff --git a/src/services/graphql/comments.ts b/src/services/graphql/comments.ts
deleted file mode 100644
index 4eaeac7..0000000
--- a/src/services/graphql/comments.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import {
- type ContentId,
- type GraphQLEdgesInput,
- type RawComment,
- type RawCommentsPage,
- type SendCommentInput,
- type SentComment,
- type SingleComment,
-} from '../../types';
-import { getAuthorFromRawData } from '../../utils/helpers';
-import { fetchAPI } from './api';
-import { sendCommentMutation } from './comments.mutation';
-import { commentsQuery } from './comments.query';
-
-/**
- * Convert a comment from RawComment type to SingleComment type.
- *
- * @param {RawComment} comment - A raw comment.
- * @returns {SingleComment} A formatted comment.
- */
-export const getCommentFromRawData = (comment: RawComment): SingleComment => {
- const { author, databaseId, date, parentDatabaseId, ...data } = comment;
-
- return {
- id: databaseId,
- meta: {
- author: getAuthorFromRawData(author.node, 'comment'),
- date,
- },
- parentId: parentDatabaseId === 0 ? undefined : parentDatabaseId,
- replies: [],
- ...data,
- };
-};
-
-/**
- * Convert an array of RawComment type to an array of SingleComment type.
- *
- * @param {RawComment[]} comments - The raw comments.
- * @returns {SingleComment[]} The formatted comments.
- */
-export const getCommentsFromRawData = (
- comments: RawComment[]
-): SingleComment[] => {
- return comments.map((comment) => getCommentFromRawData(comment));
-};
-
-/**
- * Create a comments tree with replies.
- *
- * @param {SingleComment[]} comments - A flatten comments list.
- * @returns {SingleComment[]} An array of comments with replies.
- */
-export const buildCommentsTree = (
- comments: SingleComment[]
-): SingleComment[] => {
- type CommentsHashTable = {
- [key: string]: SingleComment;
- };
-
- const hashTable: CommentsHashTable = Object.create(null);
- const commentsTree: SingleComment[] = [];
-
- comments.forEach(
- (comment) => (hashTable[comment.id] = { ...comment, replies: [] })
- );
-
- comments.forEach((comment) => {
- if (!comment.parentId) {
- commentsTree.push(hashTable[comment.id]);
- } else {
- hashTable[comment.parentId].replies.push(hashTable[comment.id]);
- }
- });
-
- return commentsTree;
-};
-
-type FetchCommentsInput = ContentId &
- Pick<GraphQLEdgesInput, 'after' | 'first'>;
-
-/**
- * Retrieve a raw comments page from GraphQL.
- *
- * @param {FetchCommentsInput} variables - An object of variables.
- * @returns {Promise<RawCommentsPage>} A raw comments page.
- */
-export const fetchRawComments = async (
- variables: FetchCommentsInput
-): Promise<RawCommentsPage> => {
- const response = await fetchAPI<RawComment, typeof commentsQuery>({
- query: commentsQuery,
- variables,
- });
-
- return {
- comments: response.comments.edges.map((edge) => edge.node),
- hasNextPage: response.comments.pageInfo.hasNextPage,
- endCursor: response.comments.pageInfo.endCursor,
- };
-};
-
-/**
- * Fetch recursively all the comments on a post.
- *
- * @param {FetchCommentsInput} variables - An object of query variables.
- * @param {RawCommentsPage[]} pages - An accumulator to keep track of pages.
- * @returns {Promise<RawCommentsPage[]>} The raw comments pages.
- */
-export const fetchAllRawCommentsPages = async (
- variables: FetchCommentsInput,
- pages: RawCommentsPage[] = []
-): Promise<RawCommentsPage[]> => {
- return fetchRawComments(variables).then((page) => {
- pages.push(page);
-
- if (page.hasNextPage) {
- return fetchAllRawCommentsPages(
- { ...variables, after: page.endCursor },
- pages
- );
- } else {
- return pages;
- }
- });
-};
-
-/**
- * Method to compare two comments dates and sort them from older to newest.
- *
- * @param {SingleComment} a - A comment.
- * @param {SingleComment} b - Another comment.
- * @returns {number} The difference between dates.
- */
-export const compareCommentsDate = (
- a: SingleComment,
- b: SingleComment
-): number => {
- return +new Date(a.meta.date) - +new Date(b.meta.date);
-};
-
-/**
- * Retrieve all the comments on a post.
- *
- * @param {number} id - A post id.
- * @returns {Promise<SingleComment[]>} The comments list.
- */
-export const getAllComments = async ({
- contentId,
-}: {
- contentId: number;
-}): Promise<SingleComment[]> => {
- const pages = await fetchAllRawCommentsPages({ contentId });
- const comments = pages
- .map((page) => getCommentsFromRawData(page.comments))
- .flat()
- .sort(compareCommentsDate);
-
- return buildCommentsTree(comments);
-};
-
-/**
- * Send a comment using GraphQL API.
- *
- * @param {SendCommentVars} data - The comment data.
- * @returns {Promise<SentComment>} The mutation response.
- */
-export const sendComment = async (
- data: SendCommentInput
-): Promise<SentComment> => {
- const response = await fetchAPI<SentComment, typeof sendCommentMutation>({
- query: sendCommentMutation,
- variables: { ...data },
- });
-
- return response.createComment;
-};
diff --git a/src/services/graphql/contact.mutation.ts b/src/services/graphql/contact.mutation.ts
deleted file mode 100644
index b82fc07..0000000
--- a/src/services/graphql/contact.mutation.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Send mail mutation.
- */
-export const sendMailMutation = `mutation SendEmail(
- $subject: String!
- $body: String!
- $replyTo: String!
- $clientMutationId: String!
-) {
- sendEmail(
- input: {
- clientMutationId: $clientMutationId
- body: $body
- replyTo: $replyTo
- subject: $subject
- }
- ) {
- clientMutationId
- message
- sent
- origin
- replyTo
- to
- }
-}`;
diff --git a/src/services/graphql/contact.ts b/src/services/graphql/contact.ts
deleted file mode 100644
index 3098374..0000000
--- a/src/services/graphql/contact.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { SendMailInput } from '../../types';
-import { fetchAPI } from './api';
-import { sendMailMutation } from './contact.mutation';
-
-export type SentEmail = {
- clientMutationId: string;
- message: string;
- origin: string;
- replyTo: string;
- sent: boolean;
-};
-
-/**
- * Send an email using GraphQL API.
- *
- * @param {SendMailInput} data - The mail data.
- * @returns {Promise<SentEmail>} The mutation response.
- */
-export const sendMail = async (data: SendMailInput): Promise<SentEmail> => {
- const response = await fetchAPI<SentEmail, typeof sendMailMutation>({
- query: sendMailMutation,
- variables: { ...data },
- });
-
- return response.sendEmail;
-};
diff --git a/src/services/graphql/fetchers/comments/fetch-comments.ts b/src/services/graphql/fetchers/comments/fetch-comments.ts
new file mode 100644
index 0000000..85ae6c1
--- /dev/null
+++ b/src/services/graphql/fetchers/comments/fetch-comments.ts
@@ -0,0 +1,65 @@
+import type {
+ GraphQLCommentWhere,
+ GraphQLEdgesInput,
+ GraphQLNodes,
+ Nullable,
+ WPComment,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type CommentsListResponse = {
+ comments: Nullable<GraphQLNodes<WPComment>>;
+};
+
+const commentsListQuery = `query CommentsList($first: Int, $contentId: ID, $contentName: String, $status: String) {
+ comments(
+ first: $first
+ where: {contentId: $contentId, contentName: $contentName, order: ASC, orderby: COMMENT_DATE, status: $status}
+ ) {
+ nodes {
+ approved
+ author {
+ node {
+ avatar {
+ height
+ url
+ width
+ }
+ name
+ url
+ }
+ }
+ content
+ databaseId
+ date
+ parentDatabaseId
+ status
+ }
+ }
+}`;
+
+export type FetchCommentsListInput = Pick<GraphQLEdgesInput, 'first'> & {
+ where?: GraphQLCommentWhere;
+};
+
+/**
+ * Retrieve the comments list.
+ *
+ * @param {FetchCommentsListInput} input - The input to retrieve comments.
+ * @returns {Promise<WPComment[]>} An array of comments.
+ */
+export const fetchCommentsList = async ({
+ where,
+ ...vars
+}: FetchCommentsListInput): Promise<WPComment[]> => {
+ const response = await fetchGraphQL<CommentsListResponse>({
+ query: commentsListQuery,
+ url: getGraphQLUrl(),
+ variables: { ...vars, ...where },
+ });
+
+ if (!response.comments)
+ return Promise.reject(new Error('No comments found.'));
+
+ return response.comments.nodes;
+};
diff --git a/src/services/graphql/fetchers/comments/index.ts b/src/services/graphql/fetchers/comments/index.ts
new file mode 100644
index 0000000..6a15970
--- /dev/null
+++ b/src/services/graphql/fetchers/comments/index.ts
@@ -0,0 +1 @@
+export * from './fetch-comments';
diff --git a/src/services/graphql/fetchers/index.ts b/src/services/graphql/fetchers/index.ts
new file mode 100644
index 0000000..f45b1c0
--- /dev/null
+++ b/src/services/graphql/fetchers/index.ts
@@ -0,0 +1,4 @@
+export * from './comments';
+export * from './posts';
+export * from './thematics';
+export * from './topics';
diff --git a/src/services/graphql/fetchers/posts/fetch-all-posts-slugs.ts b/src/services/graphql/fetchers/posts/fetch-all-posts-slugs.ts
new file mode 100644
index 0000000..28f2bbf
--- /dev/null
+++ b/src/services/graphql/fetchers/posts/fetch-all-posts-slugs.ts
@@ -0,0 +1,34 @@
+import type { GraphQLNodes, Nullable, SlugNode } from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+import { fetchPostsCount } from './fetch-posts-count';
+
+type PostsSlugsResponse = {
+ posts: Nullable<GraphQLNodes<SlugNode>>;
+};
+
+const postsSlugsQuery = `query PostsSlugs($first: Int) {
+ posts(first: $first) {
+ nodes {
+ slug
+ }
+ }
+}`;
+
+/**
+ * Retrieve the WordPress posts slugs.
+ *
+ * @returns {Promise<string[]>} The posts slugs.
+ */
+export const fetchAllPostsSlugs = async (): Promise<string[]> => {
+ const postsCount = await fetchPostsCount();
+ const response = await fetchGraphQL<PostsSlugsResponse>({
+ query: postsSlugsQuery,
+ url: getGraphQLUrl(),
+ variables: { first: postsCount },
+ });
+
+ if (!response.posts)
+ return Promise.reject(new Error('Unable to find the posts slugs.'));
+
+ return response.posts.nodes.map((node) => node.slug);
+};
diff --git a/src/services/graphql/fetchers/posts/fetch-last-post-cursor.ts b/src/services/graphql/fetchers/posts/fetch-last-post-cursor.ts
new file mode 100644
index 0000000..d5ed174
--- /dev/null
+++ b/src/services/graphql/fetchers/posts/fetch-last-post-cursor.ts
@@ -0,0 +1,37 @@
+import type { GraphQLPageInfo, Nullable } from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type LastPostCursorResponse = {
+ posts: Nullable<{
+ pageInfo: Pick<GraphQLPageInfo, 'endCursor'>;
+ }>;
+};
+
+const lastPostCursorQuery = `query LastPostCursor($first: Int) {
+ posts(first: $first) {
+ pageInfo {
+ endCursor
+ }
+ }
+}`;
+
+/**
+ * Retrieve the cursor of the last post for a given number of posts.
+ *
+ * @param {number} count - The number of posts to fetch.
+ * @returns {Promise<string>} The cursor of the last post.
+ */
+export const fetchLastPostCursor = async (count: number): Promise<string> => {
+ const response = await fetchGraphQL<LastPostCursorResponse>({
+ url: getGraphQLUrl(),
+ query: lastPostCursorQuery,
+ variables: { first: count },
+ });
+
+ if (!response.posts?.pageInfo.endCursor)
+ return Promise.reject(
+ new Error('Unable to find the cursor of the last post.')
+ );
+
+ return response.posts.pageInfo.endCursor;
+};
diff --git a/src/services/graphql/fetchers/posts/fetch-post.ts b/src/services/graphql/fetchers/posts/fetch-post.ts
new file mode 100644
index 0000000..53c6bc3
--- /dev/null
+++ b/src/services/graphql/fetchers/posts/fetch-post.ts
@@ -0,0 +1,92 @@
+import type { Nullable, WPPost } from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type PostResponse = {
+ post: Nullable<WPPost>;
+};
+
+const postQuery = `query Post($slug: ID!) {
+ post(id: $slug, idType: SLUG) {
+ acfPosts {
+ postsInThematic {
+ ... on Thematic {
+ databaseId
+ slug
+ title
+ }
+ }
+ postsInTopic {
+ ... on Topic {
+ databaseId
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ slug
+ title
+ }
+ }
+ }
+ author {
+ node {
+ name
+ }
+ }
+ commentCount
+ contentParts {
+ afterMore
+ beforeMore
+ }
+ databaseId
+ date
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ info {
+ wordsCount
+ }
+ modified
+ seo {
+ metaDesc
+ title
+ }
+ slug
+ title
+ }
+}`;
+
+/**
+ * Retrieve a WordPress post by slug.
+ *
+ * @param {string} slug - The post slug.
+ * @returns {Promise<WPPost>} The requested post.
+ */
+export const fetchPost = async (slug: string): Promise<WPPost> => {
+ const response = await fetchGraphQL<PostResponse>({
+ query: postQuery,
+ url: getGraphQLUrl(),
+ variables: { slug },
+ });
+
+ if (!response.post)
+ return Promise.reject(
+ new Error(`No post found for the following slug ${slug}.`)
+ );
+
+ return response.post;
+};
diff --git a/src/services/graphql/fetchers/posts/fetch-posts-count.ts b/src/services/graphql/fetchers/posts/fetch-posts-count.ts
new file mode 100644
index 0000000..a72af52
--- /dev/null
+++ b/src/services/graphql/fetchers/posts/fetch-posts-count.ts
@@ -0,0 +1,43 @@
+import type {
+ GraphQLPageInfo,
+ GraphQLPostWhere,
+ Nullable,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type PostsCountResponse = {
+ posts: Nullable<{
+ pageInfo: Pick<GraphQLPageInfo, 'total'>;
+ }>;
+};
+
+const postsCountQuery = `query PostsCount($authorName: String, $search: String, $title: String) {
+ posts(where: {authorName: $authorName, search: $search, title: $title}) {
+ pageInfo {
+ total
+ }
+ }
+}`;
+
+/**
+ * Retrieve the total of WordPress posts.
+ *
+ * @param {GraphQLPostWhere} [input] - The input to filter the posts.
+ * @returns {Promise<number>} The total number of posts.
+ */
+export const fetchPostsCount = async (
+ input?: GraphQLPostWhere
+): Promise<number> => {
+ const response = await fetchGraphQL<PostsCountResponse>({
+ query: postsCountQuery,
+ url: getGraphQLUrl(),
+ variables: { ...input },
+ });
+
+ if (!response.posts)
+ return Promise.reject(
+ new Error('Unable to find the total number of posts.')
+ );
+
+ return response.posts.pageInfo.total;
+};
diff --git a/src/services/graphql/fetchers/posts/fetch-posts-list.ts b/src/services/graphql/fetchers/posts/fetch-posts-list.ts
new file mode 100644
index 0000000..452892b
--- /dev/null
+++ b/src/services/graphql/fetchers/posts/fetch-posts-list.ts
@@ -0,0 +1,97 @@
+import type {
+ GraphQLConnection,
+ GraphQLEdgesInput,
+ GraphQLPostOrderBy,
+ GraphQLPostWhere,
+ Nullable,
+ WPPostPreview,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type PostsListResponse = {
+ posts: Nullable<GraphQLConnection<WPPostPreview>>;
+};
+
+const postsListQuery = `query PostsList($after: String, $before: String, $first: Int, $last: Int, $authorName: String, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) {
+ posts(
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ where: {authorName: $authorName, orderby: $orderby, search: $search, title: $title}
+ ) {
+ edges {
+ cursor
+ node {
+ acfPosts {
+ postsInThematic {
+ ... on Thematic {
+ databaseId
+ slug
+ title
+ }
+ }
+ }
+ commentCount
+ contentParts {
+ beforeMore
+ }
+ databaseId
+ date
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ info {
+ wordsCount
+ }
+ modified
+ slug
+ title
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ total
+ }
+ }
+}`;
+
+export type FetchPostsListInput = GraphQLEdgesInput & {
+ orderBy?: GraphQLPostOrderBy;
+ where?: GraphQLPostWhere;
+};
+
+/**
+ * Retrieve a paginated list of WordPress posts.
+ *
+ * @param {FetchPostsListInput} input - The input to retrieve posts.
+ * @returns {Promise<GraphQLConnection<WPPostPreview>>} The paginated posts.
+ */
+export const fetchPostsList = async ({
+ orderBy,
+ where,
+ ...vars
+}: FetchPostsListInput): Promise<GraphQLConnection<WPPostPreview>> => {
+ const response = await fetchGraphQL<PostsListResponse>({
+ query: postsListQuery,
+ url: getGraphQLUrl(),
+ variables: {
+ ...vars,
+ ...where,
+ orderBy: orderBy ? [orderBy] : undefined,
+ },
+ });
+
+ if (!response.posts) return Promise.reject(new Error('No posts found.'));
+
+ return response.posts;
+};
diff --git a/src/services/graphql/fetchers/posts/fetch-recent-posts.ts b/src/services/graphql/fetchers/posts/fetch-recent-posts.ts
new file mode 100644
index 0000000..12785d6
--- /dev/null
+++ b/src/services/graphql/fetchers/posts/fetch-recent-posts.ts
@@ -0,0 +1,76 @@
+import type {
+ GraphQLConnection,
+ GraphQLEdgesInput,
+ GraphQLPostWhere,
+ Nullable,
+ RecentWPPost,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type RecentPostsResponse = {
+ posts: Nullable<GraphQLConnection<RecentWPPost>>;
+};
+
+const recentPostsQuery = `query RecentPosts($after: String, $before: String, $first: Int, $last: Int, $authorName: String, $search: String, $title: String) {
+ posts(
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ where: {authorName: $authorName, search: $search, title: $title, orderby: {field: DATE, order: DESC}}
+ ) {
+ edges {
+ cursor
+ node {
+ databaseId
+ date
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ slug
+ title
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ total
+ }
+ }
+}`;
+
+export type FetchRecentPostsInput = GraphQLEdgesInput & {
+ where?: GraphQLPostWhere;
+};
+
+/**
+ * Retrieve a paginated list of recent WordPress posts.
+ *
+ * @param {FetchRecentPostsInput} input - The input to retrieve recent posts.
+ * @returns {Promise<GraphQLConnection<RecentWPPost>>} The recent posts.
+ */
+export const fetchRecentPosts = async ({
+ where,
+ ...vars
+}: FetchRecentPostsInput): Promise<GraphQLConnection<RecentWPPost>> => {
+ const response = await fetchGraphQL<RecentPostsResponse>({
+ query: recentPostsQuery,
+ url: getGraphQLUrl(),
+ variables: { ...vars, ...where },
+ });
+
+ if (!response.posts)
+ return Promise.reject(new Error('No recent posts found.'));
+
+ return response.posts;
+};
diff --git a/src/services/graphql/fetchers/posts/index.ts b/src/services/graphql/fetchers/posts/index.ts
new file mode 100644
index 0000000..fd725cd
--- /dev/null
+++ b/src/services/graphql/fetchers/posts/index.ts
@@ -0,0 +1,6 @@
+export * from './fetch-all-posts-slugs';
+export * from './fetch-last-post-cursor';
+export * from './fetch-post';
+export * from './fetch-posts-count';
+export * from './fetch-posts-list';
+export * from './fetch-recent-posts';
diff --git a/src/services/graphql/fetchers/thematics/fetch-all-thematics-slugs.ts b/src/services/graphql/fetchers/thematics/fetch-all-thematics-slugs.ts
new file mode 100644
index 0000000..739c009
--- /dev/null
+++ b/src/services/graphql/fetchers/thematics/fetch-all-thematics-slugs.ts
@@ -0,0 +1,34 @@
+import type { GraphQLNodes, Nullable, SlugNode } from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+import { fetchThematicsCount } from './fetch-thematics-count';
+
+type ThematicsSlugsResponse = {
+ thematics: Nullable<GraphQLNodes<SlugNode>>;
+};
+
+const thematicsSlugsQuery = `query ThematicsSlugs($first: Int) {
+ thematics(first: $first) {
+ nodes {
+ slug
+ }
+ }
+}`;
+
+/**
+ * Retrieve the WordPress thematics slugs.
+ *
+ * @returns {Promise<string[]>} The thematics slugs.
+ */
+export const fetchAllThematicsSlugs = async (): Promise<string[]> => {
+ const thematicsCount = await fetchThematicsCount();
+ const response = await fetchGraphQL<ThematicsSlugsResponse>({
+ query: thematicsSlugsQuery,
+ url: getGraphQLUrl(),
+ variables: { first: thematicsCount },
+ });
+
+ if (!response.thematics)
+ return Promise.reject(new Error('Unable to find the thematics slugs.'));
+
+ return response.thematics.nodes.map((node) => node.slug);
+};
diff --git a/src/services/graphql/fetchers/thematics/fetch-thematic.ts b/src/services/graphql/fetchers/thematics/fetch-thematic.ts
new file mode 100644
index 0000000..a9958bc
--- /dev/null
+++ b/src/services/graphql/fetchers/thematics/fetch-thematic.ts
@@ -0,0 +1,96 @@
+import type { Nullable, WPThematic } from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type ThematicResponse = {
+ thematic: Nullable<WPThematic>;
+};
+
+const thematicQuery = `query Thematic($slug: ID!) {
+ thematic(id: $slug, idType: SLUG) {
+ acfThematics {
+ postsInThematic {
+ ... on Post {
+ acfPosts {
+ postsInTopic {
+ ... on Topic {
+ databaseId
+ slug
+ title
+ }
+ }
+ }
+ author {
+ node {
+ name
+ }
+ }
+ commentCount
+ contentParts {
+ beforeMore
+ }
+ databaseId
+ date
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ info {
+ wordsCount
+ }
+ modified
+ slug
+ title
+ }
+ }
+ }
+ contentParts {
+ afterMore
+ beforeMore
+ }
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ seo {
+ metaDesc
+ title
+ }
+ slug
+ title
+ }
+}`;
+
+/**
+ * Retrieve a WordPress thematic by slug.
+ *
+ * @param {string} slug - The thematic slug.
+ * @returns {Promise<WPThematic>} The requested thematic.
+ */
+export const fetchThematic = async (slug: string): Promise<WPThematic> => {
+ const response = await fetchGraphQL<ThematicResponse>({
+ query: thematicQuery,
+ url: getGraphQLUrl(),
+ variables: { slug },
+ });
+
+ if (!response.thematic)
+ return Promise.reject(
+ new Error(`No thematic found for the following slug ${slug}.`)
+ );
+
+ return response.thematic;
+};
diff --git a/src/services/graphql/fetchers/thematics/fetch-thematics-count.ts b/src/services/graphql/fetchers/thematics/fetch-thematics-count.ts
new file mode 100644
index 0000000..29a3b17
--- /dev/null
+++ b/src/services/graphql/fetchers/thematics/fetch-thematics-count.ts
@@ -0,0 +1,43 @@
+import type {
+ GraphQLPageInfo,
+ GraphQLTaxonomyWhere,
+ Nullable,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type ThematicsCountResponse = {
+ thematics: Nullable<{
+ pageInfo: Pick<GraphQLPageInfo, 'total'>;
+ }>;
+};
+
+const thematicsCountQuery = `query ThematicsCount($search: String, $title: String) {
+ thematics(where: {search: $search, title: $title}) {
+ pageInfo {
+ total
+ }
+ }
+}`;
+
+/**
+ * Retrieve the total of WordPress thematics.
+ *
+ * @param {GraphQLTaxonomyWhere} [input] - The input to filter the thematics.
+ * @returns {Promise<number>} The total number of thematics.
+ */
+export const fetchThematicsCount = async (
+ input?: GraphQLTaxonomyWhere
+): Promise<number> => {
+ const response = await fetchGraphQL<ThematicsCountResponse>({
+ query: thematicsCountQuery,
+ url: getGraphQLUrl(),
+ variables: { ...input },
+ });
+
+ if (!response.thematics)
+ return Promise.reject(
+ new Error('Unable to find the total number of thematics.')
+ );
+
+ return response.thematics.pageInfo.total;
+};
diff --git a/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts b/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts
new file mode 100644
index 0000000..f4d22c6
--- /dev/null
+++ b/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts
@@ -0,0 +1,78 @@
+import type {
+ GraphQLConnection,
+ GraphQLEdgesInput,
+ GraphQLTaxonomyOrderBy,
+ GraphQLTaxonomyWhere,
+ Nullable,
+ WPThematicPreview,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type ThematicsListResponse = {
+ thematics: Nullable<GraphQLConnection<WPThematicPreview>>;
+};
+
+const thematicsListQuery = `query ThematicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) {
+ thematics(
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ where: {orderby: $orderby, search: $search, title: $title}
+ ) {
+ edges {
+ cursor
+ node {
+ contentParts {
+ beforeMore
+ }
+ databaseId
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ slug
+ title
+ }
+ }
+ }
+}`;
+
+export type FetchThematicsListInput = GraphQLEdgesInput & {
+ orderBy?: GraphQLTaxonomyOrderBy;
+ where?: GraphQLTaxonomyWhere;
+};
+
+/**
+ * Retrieve a paginated list of WordPress thematics.
+ *
+ * @param {FetchThematicsListInput} input - The input to retrieve thematics.
+ * @returns {Promise<GraphQLConnection<WPThematicPreview>>} The paginated thematics.
+ */
+export const fetchThematicsList = async ({
+ orderBy,
+ where,
+ ...vars
+}: FetchThematicsListInput): Promise<GraphQLConnection<WPThematicPreview>> => {
+ const response = await fetchGraphQL<ThematicsListResponse>({
+ query: thematicsListQuery,
+ url: getGraphQLUrl(),
+ variables: {
+ ...vars,
+ ...where,
+ orderBy: orderBy ? [orderBy] : undefined,
+ },
+ });
+
+ if (!response.thematics)
+ return Promise.reject(new Error('No thematics found.'));
+
+ return response.thematics;
+};
diff --git a/src/services/graphql/fetchers/thematics/index.ts b/src/services/graphql/fetchers/thematics/index.ts
new file mode 100644
index 0000000..c002793
--- /dev/null
+++ b/src/services/graphql/fetchers/thematics/index.ts
@@ -0,0 +1,4 @@
+export * from './fetch-all-thematics-slugs';
+export * from './fetch-thematic';
+export * from './fetch-thematics-count';
+export * from './fetch-thematics-list';
diff --git a/src/services/graphql/fetchers/topics/fetch-all-topics-slugs.ts b/src/services/graphql/fetchers/topics/fetch-all-topics-slugs.ts
new file mode 100644
index 0000000..eab4a7c
--- /dev/null
+++ b/src/services/graphql/fetchers/topics/fetch-all-topics-slugs.ts
@@ -0,0 +1,34 @@
+import type { GraphQLNodes, Nullable, SlugNode } from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+import { fetchTopicsCount } from './fetch-topics-count';
+
+type TopicsSlugsResponse = {
+ topics: Nullable<GraphQLNodes<SlugNode>>;
+};
+
+const topicsSlugsQuery = `query TopicsSlugs($first: Int) {
+ topics(first: $first) {
+ nodes {
+ slug
+ }
+ }
+}`;
+
+/**
+ * Retrieve the WordPress topics slugs.
+ *
+ * @returns {Promise<string[]>} The topics slugs.
+ */
+export const fetchAllTopicsSlugs = async (): Promise<string[]> => {
+ const topicsCount = await fetchTopicsCount();
+ const response = await fetchGraphQL<TopicsSlugsResponse>({
+ query: topicsSlugsQuery,
+ url: getGraphQLUrl(),
+ variables: { first: topicsCount },
+ });
+
+ if (!response.topics)
+ return Promise.reject(new Error('Unable to find the topics slugs.'));
+
+ return response.topics.nodes.map((node) => node.slug);
+};
diff --git a/src/services/graphql/fetchers/topics/fetch-topic.ts b/src/services/graphql/fetchers/topics/fetch-topic.ts
new file mode 100644
index 0000000..efc1d9e
--- /dev/null
+++ b/src/services/graphql/fetchers/topics/fetch-topic.ts
@@ -0,0 +1,97 @@
+import type { Nullable, WPTopic } from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type TopicResponse = {
+ topic: Nullable<WPTopic>;
+};
+
+const topicQuery = `query Topic($slug: ID!) {
+ topic(id: $slug, idType: SLUG) {
+ acfTopics {
+ officialWebsite
+ postsInTopic {
+ ... on Post {
+ acfPosts {
+ postsInThematic {
+ ... on Thematic {
+ databaseId
+ slug
+ title
+ }
+ }
+ }
+ author {
+ node {
+ name
+ }
+ }
+ commentCount
+ contentParts {
+ beforeMore
+ }
+ databaseId
+ date
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ info {
+ wordsCount
+ }
+ modified
+ slug
+ title
+ }
+ }
+ }
+ contentParts {
+ afterMore
+ beforeMore
+ }
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ sourceUrl
+ title
+ }
+ }
+ seo {
+ metaDesc
+ title
+ }
+ slug
+ title
+ }
+}`;
+
+/**
+ * Retrieve a WordPress topic by slug.
+ *
+ * @param {string} slug - The topic slug.
+ * @returns {Promise<WPTopic>} The requested topic.
+ */
+export const fetchTopic = async (slug: string): Promise<WPTopic> => {
+ const response = await fetchGraphQL<TopicResponse>({
+ query: topicQuery,
+ url: getGraphQLUrl(),
+ variables: { slug },
+ });
+
+ if (!response.topic)
+ return Promise.reject(
+ new Error(`No topic found for the following slug ${slug}.`)
+ );
+
+ return response.topic;
+};
diff --git a/src/services/graphql/fetchers/topics/fetch-topics-count.ts b/src/services/graphql/fetchers/topics/fetch-topics-count.ts
new file mode 100644
index 0000000..868b01e
--- /dev/null
+++ b/src/services/graphql/fetchers/topics/fetch-topics-count.ts
@@ -0,0 +1,43 @@
+import type {
+ GraphQLPageInfo,
+ GraphQLTaxonomyWhere,
+ Nullable,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type TopicsCountResponse = {
+ topics: Nullable<{
+ pageInfo: Pick<GraphQLPageInfo, 'total'>;
+ }>;
+};
+
+const topicsCountQuery = `query TopicsCount($search: String, $title: String) {
+ topics(where: {search: $search, title: $title}) {
+ pageInfo {
+ total
+ }
+ }
+}`;
+
+/**
+ * Retrieve the total of WordPress topics.
+ *
+ * @param {GraphQLTaxonomyWhere} [input] - The input to filter the topics.
+ * @returns {Promise<number>} The total number of topics.
+ */
+export const fetchTopicsCount = async (
+ input?: GraphQLTaxonomyWhere
+): Promise<number> => {
+ const response = await fetchGraphQL<TopicsCountResponse>({
+ query: topicsCountQuery,
+ url: getGraphQLUrl(),
+ variables: { ...input },
+ });
+
+ if (!response.topics)
+ return Promise.reject(
+ new Error('Unable to find the total number of topics.')
+ );
+
+ return response.topics.pageInfo.total;
+};
diff --git a/src/services/graphql/fetchers/topics/fetch-topics-list.ts b/src/services/graphql/fetchers/topics/fetch-topics-list.ts
new file mode 100644
index 0000000..1bc2e38
--- /dev/null
+++ b/src/services/graphql/fetchers/topics/fetch-topics-list.ts
@@ -0,0 +1,84 @@
+import type {
+ GraphQLConnection,
+ GraphQLEdgesInput,
+ GraphQLTaxonomyOrderBy,
+ GraphQLTaxonomyWhere,
+ Nullable,
+ WPTopicPreview,
+} from '../../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers';
+
+type TopicsListResponse = {
+ topics: Nullable<GraphQLConnection<WPTopicPreview>>;
+};
+
+const topicsListQuery = `query TopicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) {
+ topics(
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ where: {orderby: $orderby, search: $search, title: $title}
+ ) {
+ edges {
+ cursor
+ node {
+ contentParts {
+ beforeMore
+ }
+ databaseId
+ featuredImage {
+ node {
+ altText
+ mediaDetails {
+ height
+ width
+ }
+ slug
+ title
+ }
+ }
+ slug
+ title
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ total
+ }
+ }
+}`;
+
+export type FetchTopicsListInput = GraphQLEdgesInput & {
+ orderBy?: GraphQLTaxonomyOrderBy;
+ where?: GraphQLTaxonomyWhere;
+};
+
+/**
+ * Retrieve a paginated list of WordPress topics.
+ *
+ * @param {FetchTopicsListInput} input - The input to retrieve topics.
+ * @returns {Promise<GraphQLConnection<WPTopicPreview>>} The paginated topics.
+ */
+export const fetchTopicsList = async ({
+ orderBy,
+ where,
+ ...vars
+}: FetchTopicsListInput): Promise<GraphQLConnection<WPTopicPreview>> => {
+ const response = await fetchGraphQL<TopicsListResponse>({
+ query: topicsListQuery,
+ url: getGraphQLUrl(),
+ variables: {
+ ...vars,
+ ...where,
+ orderBy: orderBy ? [orderBy] : undefined,
+ },
+ });
+
+ if (!response.topics) return Promise.reject(new Error('No topics found.'));
+
+ return response.topics;
+};
diff --git a/src/services/graphql/fetchers/topics/index.ts b/src/services/graphql/fetchers/topics/index.ts
new file mode 100644
index 0000000..e381883
--- /dev/null
+++ b/src/services/graphql/fetchers/topics/index.ts
@@ -0,0 +1,4 @@
+export * from './fetch-all-topics-slugs';
+export * from './fetch-topic';
+export * from './fetch-topics-count';
+export * from './fetch-topics-list';
diff --git a/src/services/graphql/helpers/build-comments-tree.test.ts b/src/services/graphql/helpers/build-comments-tree.test.ts
new file mode 100644
index 0000000..cd9fa40
--- /dev/null
+++ b/src/services/graphql/helpers/build-comments-tree.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from '@jest/globals';
+import type { SingleComment } from '../../../types';
+import { buildCommentsTree } from './build-comments-tree';
+
+describe('build-comments-tree', () => {
+ it('transforms a flat comments array to a comments tree', () => {
+ const firstComment = {
+ content: 'Non non provident mollitia a.',
+ id: 1,
+ isApproved: true,
+ meta: { author: { name: 'Emma_Muller' }, date: '2022-11-02' },
+ replies: [],
+ } satisfies SingleComment;
+ const firstCommentReplies = [
+ {
+ content: 'Et omnis voluptatem est atque.',
+ id: 3,
+ isApproved: true,
+ meta: { author: { name: 'Patrick.Goodwin44' }, date: '2022-11-05' },
+ replies: [],
+ parentId: 1,
+ },
+ ] satisfies SingleComment[];
+ const secondComment = {
+ content: 'Vero iure architecto modi iusto qui.',
+ id: 2,
+ isApproved: true,
+ meta: { author: { name: 'Dominique13' }, date: '2022-11-04' },
+ replies: [],
+ } satisfies SingleComment;
+ const secondCommentReplies = [
+ {
+ content: 'Qui quaerat quas quia praesentium quasi.',
+ id: 4,
+ isApproved: true,
+ meta: { author: { name: 'Patrick.Goodwin44' }, date: '2022-11-05' },
+ replies: [],
+ parentId: 2,
+ },
+ {
+ content: 'Ut officia aliquid harum voluptas molestiae quo.',
+ id: 5,
+ isApproved: true,
+ meta: { author: { name: 'Ariel.Braun6' }, date: '2022-11-06' },
+ replies: [],
+ parentId: 2,
+ },
+ ] satisfies SingleComment[];
+ const comments: SingleComment[] = [
+ firstComment,
+ secondComment,
+ ...firstCommentReplies,
+ ...secondCommentReplies,
+ ];
+ const result = buildCommentsTree(comments);
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toStrictEqual({
+ ...firstComment,
+ replies: firstCommentReplies,
+ });
+ expect(result[1]).toStrictEqual({
+ ...secondComment,
+ replies: secondCommentReplies,
+ });
+ });
+});
diff --git a/src/services/graphql/helpers/build-comments-tree.ts b/src/services/graphql/helpers/build-comments-tree.ts
new file mode 100644
index 0000000..1534cfe
--- /dev/null
+++ b/src/services/graphql/helpers/build-comments-tree.ts
@@ -0,0 +1,30 @@
+import type { SingleComment } from '../../../types';
+
+/**
+ * Create a comments tree with replies.
+ *
+ * @param {SingleComment[]} comments - A flatten comments list.
+ * @returns {SingleComment[]} An array of comments with replies.
+ */
+export const buildCommentsTree = (
+ comments: SingleComment[]
+): SingleComment[] => {
+ type CommentsHashTable = Record<string, SingleComment>;
+
+ const hashTable: CommentsHashTable = Object.create(null);
+ const commentsTree: SingleComment[] = [];
+
+ comments.forEach((comment) => {
+ hashTable[comment.id] = { ...comment, replies: [] };
+ });
+
+ comments.forEach((comment) => {
+ if (comment.parentId) {
+ hashTable[comment.parentId].replies.push(hashTable[comment.id]);
+ } else {
+ commentsTree.push(hashTable[comment.id]);
+ }
+ });
+
+ return commentsTree;
+};
diff --git a/src/services/graphql/helpers/convert-post-preview-to-article-preview.test.ts b/src/services/graphql/helpers/convert-post-preview-to-article-preview.test.ts
new file mode 100644
index 0000000..c13684f
--- /dev/null
+++ b/src/services/graphql/helpers/convert-post-preview-to-article-preview.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from '@jest/globals';
+import type { WPPostPreview, WPThematicPreview } from '../../../types';
+import { convertPostPreviewToArticlePreview } from './convert-post-preview-to-article-preview';
+import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+describe('convert-post-preview-to-article-preview', () => {
+ /* eslint-disable max-statements */
+ it('converts a RecentWPPost object to a RecentArticle object', () => {
+ const post: WPPostPreview = {
+ acfPosts: null,
+ commentCount: 6,
+ contentParts: {
+ beforeMore:
+ 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.',
+ },
+ databaseId: 5,
+ date: '2021-04-28',
+ featuredImage: null,
+ info: {
+ wordsCount: 450,
+ },
+ modified: '2021-04-29',
+ slug: '/the-post-slug',
+ title: 'et tempore sint',
+ };
+ const result = convertPostPreviewToArticlePreview(post);
+
+ expect(result.id).toBe(post.databaseId);
+ expect(result.intro).toBe(post.contentParts.beforeMore);
+ expect(result.meta.commentsCount).toBe(post.commentCount);
+ expect(result.meta.cover).toBeUndefined();
+ expect(result.meta.dates.publication).toBe(post.date);
+ expect(result.meta.dates.update).toBe(post.modified);
+ expect(result.meta.thematics).toBeUndefined();
+ expect(result.meta.wordsCount).toBe(post.info.wordsCount);
+ expect(result.slug).toBe(post.slug);
+ expect(result.title).toBe(post.title);
+ });
+ /* eslint-enable max-statements */
+
+ it('can return 0 as comment count if not defined', () => {
+ const post: WPPostPreview = {
+ acfPosts: null,
+ commentCount: null,
+ contentParts: {
+ beforeMore:
+ 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.',
+ },
+ databaseId: 5,
+ date: '2021-04-28',
+ featuredImage: null,
+ info: {
+ wordsCount: 450,
+ },
+ modified: '2021-04-29',
+ slug: '/the-post-slug',
+ title: 'et tempore sint',
+ };
+ const result = convertPostPreviewToArticlePreview(post);
+
+ expect(result.meta.commentsCount).toBe(0);
+ });
+
+ it('can convert the cover', () => {
+ const post = {
+ acfPosts: null,
+ commentCount: null,
+ contentParts: {
+ beforeMore:
+ 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.',
+ },
+ databaseId: 5,
+ date: '2021-04-28',
+ featuredImage: {
+ node: {
+ altText: 'molestiae praesentium animi',
+ mediaDetails: {
+ height: 480,
+ width: 640,
+ },
+ sourceUrl: 'https://picsum.photos/640/480',
+ title: 'ullam deserunt perspiciatis',
+ },
+ },
+ info: {
+ wordsCount: 450,
+ },
+ modified: '2021-04-29',
+ slug: '/the-post-slug',
+ title: 'et tempore sint',
+ } satisfies WPPostPreview;
+ const result = convertPostPreviewToArticlePreview(post);
+
+ expect(result.meta.cover).toStrictEqual(
+ convertWPImgToImg(post.featuredImage.node)
+ );
+ });
+
+ it('can convert the thematics', () => {
+ const thematics: WPThematicPreview[] = [
+ { databaseId: 2, slug: '/thematic1', title: 'aut quis vel' },
+ { databaseId: 8, slug: '/thematic2', title: 'vel sint autem' },
+ ];
+ const post: WPPostPreview = {
+ acfPosts: {
+ postsInThematic: thematics,
+ },
+ commentCount: 6,
+ contentParts: {
+ beforeMore:
+ 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.',
+ },
+ databaseId: 5,
+ date: '2021-04-28',
+ featuredImage: null,
+ info: {
+ wordsCount: 450,
+ },
+ modified: '2021-04-29',
+ slug: '/the-post-slug',
+ title: 'et tempore sint',
+ };
+ const result = convertPostPreviewToArticlePreview(post);
+
+ expect(result.meta.thematics).toStrictEqual(
+ thematics.map(convertTaxonomyToPageLink)
+ );
+ });
+});
diff --git a/src/services/graphql/helpers/convert-post-preview-to-article-preview.ts b/src/services/graphql/helpers/convert-post-preview-to-article-preview.ts
new file mode 100644
index 0000000..78777eb
--- /dev/null
+++ b/src/services/graphql/helpers/convert-post-preview-to-article-preview.ts
@@ -0,0 +1,36 @@
+import type { ArticlePreview, WPPostPreview } from '../../../types';
+import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+export const convertPostPreviewToArticlePreview = ({
+ acfPosts,
+ commentCount,
+ contentParts,
+ databaseId,
+ date,
+ featuredImage,
+ info,
+ modified,
+ slug,
+ title,
+}: WPPostPreview): ArticlePreview => {
+ return {
+ id: databaseId,
+ intro: contentParts.beforeMore,
+ meta: {
+ commentsCount: typeof commentCount === 'number' ? commentCount : 0,
+ cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined,
+ dates: {
+ publication: date,
+ update: modified,
+ },
+ thematics:
+ acfPosts && 'postsInThematic' in acfPosts
+ ? acfPosts.postsInThematic?.map(convertTaxonomyToPageLink)
+ : undefined,
+ wordsCount: info.wordsCount,
+ },
+ slug,
+ title,
+ };
+};
diff --git a/src/services/graphql/helpers/convert-post-to-article.test.ts b/src/services/graphql/helpers/convert-post-to-article.test.ts
new file mode 100644
index 0000000..0a1c359
--- /dev/null
+++ b/src/services/graphql/helpers/convert-post-to-article.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from '@jest/globals';
+import type { WPPost } from '../../../types';
+import { convertPostToArticle } from './convert-post-to-article';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+describe('convert-post-to-article', () => {
+ /* eslint-disable max-statements */
+ it('converts a WPPost object to an Article object', async () => {
+ const post: WPPost = {
+ acfPosts: null,
+ author: { node: { name: 'Vince5' } },
+ commentCount: 10,
+ contentParts: {
+ afterMore:
+ 'Eum est rerum neque placeat iure veniam enim consequatur assumenda. Quos eos placeat ea et vel sit ratione fugit. Modi qui sint iure beatae illo voluptas.',
+ beforeMore:
+ 'Omnis ab qui dolorem praesentium voluptas asperiores officiis. Id nostrum minus quae ducimus tenetur eum a rem eum. Aut odio libero sit soluta ullam odit.',
+ },
+ databaseId: 8,
+ date: '2022-05-04',
+ featuredImage: null,
+ info: { wordsCount: 300 },
+ modified: '2022-06-01',
+ seo: {
+ metaDesc: 'Est non debitis quas harum quasi voluptatem qui.',
+ title: 'consequuntur molestiae amet',
+ },
+ slug: '/the-post-slug',
+ title: 'ea vero repellat',
+ };
+ const result = await convertPostToArticle(post);
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(15);
+
+ expect(result.content).toBe(post.contentParts.afterMore);
+ expect(result.id).toBe(post.databaseId);
+ expect(result.intro).toBe(post.contentParts.beforeMore);
+ expect(result.meta.author).toBe(post.author.node.name);
+ expect(result.meta.commentsCount).toBe(post.commentCount);
+ expect(result.meta.cover).toBeUndefined();
+ expect(result.meta.dates.publication).toBe(post.date);
+ expect(result.meta.dates.update).toBe(post.modified);
+ expect(result.meta.seo.description).toBe(post.seo.metaDesc);
+ expect(result.meta.seo.title).toBe(post.seo.title);
+ expect(result.meta.thematics).toBeUndefined();
+ expect(result.meta.topics).toBeUndefined();
+ expect(result.meta.wordsCount).toBe(post.info.wordsCount);
+ expect(result.slug).toBe(post.slug);
+ expect(result.title).toBe(post.title);
+ });
+ /* eslint-enable max-statements */
+
+ it('can convert the cover', async () => {
+ const post = {
+ acfPosts: null,
+ author: { node: { name: 'Vince5' } },
+ commentCount: null,
+ contentParts: {
+ afterMore:
+ 'Eum est rerum neque placeat iure veniam enim consequatur assumenda. Quos eos placeat ea et vel sit ratione fugit. Modi qui sint iure beatae illo voluptas.',
+ beforeMore:
+ 'Omnis ab qui dolorem praesentium voluptas asperiores officiis. Id nostrum minus quae ducimus tenetur eum a rem eum. Aut odio libero sit soluta ullam odit.',
+ },
+ databaseId: 8,
+ date: '2022-05-04',
+ featuredImage: {
+ node: {
+ altText: 'molestiae praesentium animi',
+ mediaDetails: {
+ height: 480,
+ width: 640,
+ },
+ sourceUrl: 'https://picsum.photos/640/480',
+ title: 'ullam deserunt perspiciatis',
+ },
+ },
+ info: { wordsCount: 300 },
+ modified: '2022-06-01',
+ seo: {
+ metaDesc: 'Est non debitis quas harum quasi voluptatem qui.',
+ title: 'consequuntur molestiae amet',
+ },
+ slug: '/the-post-slug',
+ title: 'ea vero repellat',
+ } satisfies WPPost;
+ const result = await convertPostToArticle(post);
+
+ expect.assertions(1);
+
+ expect(result.meta.cover).toStrictEqual(
+ convertWPImgToImg(post.featuredImage.node)
+ );
+ });
+
+ it('can return 0 as comment count when not defined', async () => {
+ const post: WPPost = {
+ acfPosts: null,
+ author: { node: { name: 'Vince5' } },
+ commentCount: null,
+ contentParts: {
+ afterMore:
+ 'Eum est rerum neque placeat iure veniam enim consequatur assumenda. Quos eos placeat ea et vel sit ratione fugit. Modi qui sint iure beatae illo voluptas.',
+ beforeMore:
+ 'Omnis ab qui dolorem praesentium voluptas asperiores officiis. Id nostrum minus quae ducimus tenetur eum a rem eum. Aut odio libero sit soluta ullam odit.',
+ },
+ databaseId: 8,
+ date: '2022-05-04',
+ featuredImage: null,
+ info: { wordsCount: 300 },
+ modified: '2022-06-01',
+ seo: {
+ metaDesc: 'Est non debitis quas harum quasi voluptatem qui.',
+ title: 'consequuntur molestiae amet',
+ },
+ slug: '/the-post-slug',
+ title: 'ea vero repellat',
+ };
+ const result = await convertPostToArticle(post);
+
+ expect.assertions(1);
+
+ expect(result.meta.commentsCount).toBe(0);
+ });
+});
diff --git a/src/services/graphql/helpers/convert-post-to-article.ts b/src/services/graphql/helpers/convert-post-to-article.ts
new file mode 100644
index 0000000..b540a77
--- /dev/null
+++ b/src/services/graphql/helpers/convert-post-to-article.ts
@@ -0,0 +1,43 @@
+import type { Article, WPPost } from '../../../types';
+import { updateContentTree } from '../../../utils/helpers';
+import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+export const convertPostToArticle = async ({
+ acfPosts,
+ author,
+ commentCount,
+ contentParts,
+ databaseId,
+ date,
+ featuredImage,
+ info,
+ modified,
+ seo,
+ slug,
+ title,
+}: WPPost): Promise<Article> => {
+ return {
+ content: await updateContentTree(contentParts.afterMore),
+ id: databaseId,
+ intro: contentParts.beforeMore,
+ meta: {
+ author: author.node.name,
+ commentsCount: commentCount ?? 0,
+ cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined,
+ dates: {
+ publication: date,
+ update: modified,
+ },
+ seo: {
+ description: seo.metaDesc,
+ title: seo.title,
+ },
+ thematics: acfPosts?.postsInThematic?.map(convertTaxonomyToPageLink),
+ topics: acfPosts?.postsInTopic?.map(convertTaxonomyToPageLink),
+ wordsCount: info.wordsCount,
+ },
+ slug,
+ title,
+ };
+};
diff --git a/src/services/graphql/helpers/convert-recent-post-to-recent-article.test.ts b/src/services/graphql/helpers/convert-recent-post-to-recent-article.test.ts
new file mode 100644
index 0000000..8acf753
--- /dev/null
+++ b/src/services/graphql/helpers/convert-recent-post-to-recent-article.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from '@jest/globals';
+import type { RecentWPPost } from '../../../types';
+import { convertRecentPostToRecentArticle } from './convert-recent-post-to-recent-article';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+describe('convert-recent-post-to-recent-article', () => {
+ it('converts a RecentWPPost object to a RecentArticle object', () => {
+ const post: RecentWPPost = {
+ databaseId: 5,
+ date: '2022-03-20',
+ featuredImage: null,
+ slug: '/the-post-slug',
+ title: 'veritatis ex autem',
+ };
+ const result = convertRecentPostToRecentArticle(post);
+
+ expect(result.cover).toBeUndefined();
+ expect(result.id).toBe(post.databaseId);
+ expect(result.publicationDate).toBe(post.date);
+ expect(result.slug).toBe(post.slug);
+ expect(result.title).toBe(post.title);
+ });
+
+ it('can convert the cover', () => {
+ const post = {
+ databaseId: 5,
+ date: '2022-03-20',
+ featuredImage: {
+ node: {
+ altText: 'molestiae praesentium animi',
+ mediaDetails: {
+ height: 480,
+ width: 640,
+ },
+ sourceUrl: 'https://picsum.photos/640/480',
+ title: 'ullam deserunt perspiciatis',
+ },
+ },
+ slug: '/the-post-slug',
+ title: 'veritatis ex autem',
+ } satisfies RecentWPPost;
+ const result = convertRecentPostToRecentArticle(post);
+
+ expect(result.cover).toStrictEqual(
+ convertWPImgToImg(post.featuredImage.node)
+ );
+ });
+});
diff --git a/src/services/graphql/helpers/convert-recent-post-to-recent-article.ts b/src/services/graphql/helpers/convert-recent-post-to-recent-article.ts
new file mode 100644
index 0000000..ff5eb67
--- /dev/null
+++ b/src/services/graphql/helpers/convert-recent-post-to-recent-article.ts
@@ -0,0 +1,24 @@
+import type { RecentArticle, RecentWPPost } from '../../../types';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+/**
+ * Convert a WordPress post to an article.
+ *
+ * @param {RecentWPPost} post - A post.
+ * @returns {RecentArticle} An article.
+ */
+export const convertRecentPostToRecentArticle = ({
+ databaseId,
+ date,
+ featuredImage,
+ slug,
+ title,
+}: RecentWPPost): RecentArticle => {
+ return {
+ cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined,
+ id: databaseId,
+ publicationDate: date,
+ slug,
+ title,
+ };
+};
diff --git a/src/services/graphql/helpers/convert-taxonomy-to-page-link.test.ts b/src/services/graphql/helpers/convert-taxonomy-to-page-link.test.ts
new file mode 100644
index 0000000..b687ccb
--- /dev/null
+++ b/src/services/graphql/helpers/convert-taxonomy-to-page-link.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from '@jest/globals';
+import type { WPThematicPreview, WPTopicPreview } from '../../../types';
+import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link';
+
+describe('convert-taxonomy-to-page-link', () => {
+ it('can convert a WPThematicPreview object to a Thematic object', () => {
+ const thematic: WPThematicPreview = {
+ databaseId: 42,
+ slug: '/the-thematic-slug',
+ title: 'et ut alias',
+ };
+ const result = convertTaxonomyToPageLink(thematic);
+
+ expect(result.id).toBe(thematic.databaseId);
+ expect(result.logo).toBeUndefined();
+ expect(result.name).toBe(thematic.title);
+ expect(result.url).toBe(thematic.slug);
+ });
+
+ it('can convert a WPTopicPreview object to a Topic object', () => {
+ const topic: WPTopicPreview = {
+ databaseId: 42,
+ featuredImage: {
+ node: {
+ altText: 'dolorem quia possimus',
+ mediaDetails: {
+ height: 480,
+ width: 640,
+ },
+ sourceUrl: 'https://picsum.photos/640/480',
+ title: 'eos',
+ },
+ },
+ slug: '/the-topic-slug',
+ title: 'et ut alias',
+ };
+ const result = convertTaxonomyToPageLink(topic);
+
+ expect(result.id).toBe(topic.databaseId);
+ expect(result.logo?.alt).toBe(topic.featuredImage?.node.altText);
+ expect(result.logo?.height).toBe(
+ topic.featuredImage?.node.mediaDetails.height
+ );
+ expect(result.logo?.src).toBe(topic.featuredImage?.node.sourceUrl);
+ expect(result.logo?.title).toBe(topic.featuredImage?.node.title);
+ expect(result.logo?.width).toBe(
+ topic.featuredImage?.node.mediaDetails.width
+ );
+ expect(result.name).toBe(topic.title);
+ expect(result.url).toBe(topic.slug);
+ });
+});
diff --git a/src/services/graphql/helpers/convert-taxonomy-to-page-link.ts b/src/services/graphql/helpers/convert-taxonomy-to-page-link.ts
new file mode 100644
index 0000000..2294fb7
--- /dev/null
+++ b/src/services/graphql/helpers/convert-taxonomy-to-page-link.ts
@@ -0,0 +1,23 @@
+import type {
+ PageLink,
+ WPThematicPreview,
+ WPTopicPreview,
+} from '../../../types';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+export const convertTaxonomyToPageLink = ({
+ databaseId,
+ slug,
+ title,
+ ...props
+}: WPThematicPreview | WPTopicPreview): PageLink => {
+ return {
+ id: databaseId,
+ logo:
+ 'featuredImage' in props && props.featuredImage
+ ? convertWPImgToImg(props.featuredImage.node)
+ : undefined,
+ name: title,
+ url: slug,
+ };
+};
diff --git a/src/services/graphql/helpers/convert-wp-comment-to-comment.test.ts b/src/services/graphql/helpers/convert-wp-comment-to-comment.test.ts
new file mode 100644
index 0000000..4b385b4
--- /dev/null
+++ b/src/services/graphql/helpers/convert-wp-comment-to-comment.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from '@jest/globals';
+import type { WPComment } from '../../../types';
+import { convertWPCommentToComment } from './convert-wp-comment-to-comment';
+
+describe('convert-wp-comment-to-comment', () => {
+ it('converts a WPComment object to a Comment object', () => {
+ const comment: WPComment = {
+ approved: true,
+ author: {
+ node: {
+ avatar: null,
+ name: 'Maribel.Roberts',
+ url: null,
+ },
+ },
+ content: 'Aliquam qui et facere consequatur quia.',
+ databaseId: 4,
+ date: '2023-10-15',
+ parentDatabaseId: 1,
+ status: 'HOLD',
+ };
+
+ const transformedComment = convertWPCommentToComment(comment);
+
+ expect(transformedComment.content).toBe(comment.content);
+ expect(transformedComment.id).toBe(comment.databaseId);
+ expect(transformedComment.isApproved).toBe(comment.approved);
+ expect(transformedComment.meta.author.avatar).toBeUndefined();
+ expect(transformedComment.meta.author.name).toBe(comment.author.node.name);
+ expect(transformedComment.meta.author.website).toBeUndefined();
+ expect(transformedComment.parentId).toBe(comment.parentDatabaseId);
+ expect(transformedComment.replies).toStrictEqual([]);
+ });
+
+ it('can convert the avatar', () => {
+ const comment: WPComment = {
+ approved: true,
+ author: {
+ node: {
+ avatar: {
+ height: 80,
+ url: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/426.jpg',
+ width: 80,
+ },
+ name: 'Maribel.Roberts',
+ url: null,
+ },
+ },
+ content: 'Aliquam qui et facere consequatur quia.',
+ databaseId: 4,
+ date: '2023-10-15',
+ parentDatabaseId: 1,
+ status: 'HOLD',
+ };
+
+ const transformedComment = convertWPCommentToComment(comment);
+
+ expect(transformedComment.meta.author.avatar?.alt).toBe(
+ `${comment.author.node.name} avatar`
+ );
+ expect(transformedComment.meta.author.avatar?.height).toBe(
+ comment.author.node.avatar?.height
+ );
+ expect(transformedComment.meta.author.avatar?.src).toBe(
+ comment.author.node.avatar?.url
+ );
+ expect(transformedComment.meta.author.avatar?.width).toBe(
+ comment.author.node.avatar?.width
+ );
+ });
+
+ it('can remove the parentId when not meaningful', () => {
+ const comment: WPComment = {
+ approved: true,
+ author: {
+ node: {
+ avatar: null,
+ name: 'Maribel.Roberts',
+ url: null,
+ },
+ },
+ content: 'Aliquam qui et facere consequatur quia.',
+ databaseId: 4,
+ date: '2023-10-15',
+ parentDatabaseId: 0,
+ status: 'HOLD',
+ };
+
+ const transformedComment = convertWPCommentToComment(comment);
+
+ expect(transformedComment.parentId).toBeUndefined();
+ });
+});
diff --git a/src/services/graphql/helpers/convert-wp-comment-to-comment.ts b/src/services/graphql/helpers/convert-wp-comment-to-comment.ts
new file mode 100644
index 0000000..7a7e2ca
--- /dev/null
+++ b/src/services/graphql/helpers/convert-wp-comment-to-comment.ts
@@ -0,0 +1,35 @@
+import type { SingleComment, WPComment } from '../../../types';
+
+/**
+ * Convert a comment from WordPress type to SingleComment.
+ *
+ * @param {WPComment} comment - A raw comment from WordPress.
+ * @returns {SingleComment} A comment.
+ */
+export const convertWPCommentToComment = (
+ comment: WPComment
+): SingleComment => {
+ return {
+ content: comment.content,
+ isApproved: comment.approved,
+ id: comment.databaseId,
+ meta: {
+ author: {
+ name: comment.author.node.name,
+ avatar: comment.author.node.avatar
+ ? {
+ alt: `${comment.author.node.name} avatar`,
+ height: comment.author.node.avatar.height,
+ src: comment.author.node.avatar.url,
+ width: comment.author.node.avatar.width,
+ }
+ : undefined,
+ website: comment.author.node.url ?? undefined,
+ },
+ date: comment.date,
+ },
+ parentId:
+ comment.parentDatabaseId === 0 ? undefined : comment.parentDatabaseId,
+ replies: [],
+ };
+};
diff --git a/src/services/graphql/helpers/convert-wp-image-to-img.test.ts b/src/services/graphql/helpers/convert-wp-image-to-img.test.ts
new file mode 100644
index 0000000..ca58a4f
--- /dev/null
+++ b/src/services/graphql/helpers/convert-wp-image-to-img.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from '@jest/globals';
+import type { WPImage } from '../../../types';
+import { convertWPImgToImg } from './convert-wp-image-to-img';
+
+describe('convert-wp-image-to-img', () => {
+ it('converts a WPImage object to an Img object', () => {
+ const img: WPImage = {
+ altText: 'molestiae praesentium animi',
+ mediaDetails: {
+ height: 480,
+ width: 640,
+ },
+ sourceUrl: 'https://picsum.photos/640/480',
+ title: null,
+ };
+
+ const transformedImg = convertWPImgToImg(img);
+
+ expect(transformedImg.alt).toBe(img.altText);
+ expect(transformedImg.height).toBe(img.mediaDetails.height);
+ expect(transformedImg.src).toBe(img.sourceUrl);
+ expect(transformedImg.title).toBeUndefined();
+ expect(transformedImg.width).toBe(img.mediaDetails.width);
+ });
+
+ it('can return an empty string if altText is missing', () => {
+ const img: WPImage = {
+ altText: null,
+ mediaDetails: {
+ height: 480,
+ width: 640,
+ },
+ sourceUrl: 'https://picsum.photos/640/480',
+ title: null,
+ };
+
+ const transformedImg = convertWPImgToImg(img);
+
+ expect(transformedImg.alt).toBe('');
+ });
+});
diff --git a/src/services/graphql/helpers/convert-wp-image-to-img.ts b/src/services/graphql/helpers/convert-wp-image-to-img.ts
new file mode 100644
index 0000000..392aaf9
--- /dev/null
+++ b/src/services/graphql/helpers/convert-wp-image-to-img.ts
@@ -0,0 +1,16 @@
+import type { Img, WPImage } from '../../../types';
+
+export const convertWPImgToImg = ({
+ altText,
+ mediaDetails,
+ sourceUrl,
+ title,
+}: WPImage): Img => {
+ return {
+ alt: altText ?? '',
+ height: mediaDetails.height,
+ src: sourceUrl,
+ title: title ?? undefined,
+ width: mediaDetails.width,
+ };
+};
diff --git a/src/services/graphql/helpers/index.ts b/src/services/graphql/helpers/index.ts
new file mode 100644
index 0000000..16e93d2
--- /dev/null
+++ b/src/services/graphql/helpers/index.ts
@@ -0,0 +1,7 @@
+export * from './build-comments-tree';
+export * from './convert-post-preview-to-article-preview';
+export * from './convert-post-to-article';
+export * from './convert-recent-post-to-recent-article';
+export * from './convert-taxonomy-to-page-link';
+export * from './convert-wp-comment-to-comment';
+export * from './convert-wp-image-to-img';
diff --git a/src/services/graphql/index.ts b/src/services/graphql/index.ts
index c1eac16..53afbc7 100644
--- a/src/services/graphql/index.ts
+++ b/src/services/graphql/index.ts
@@ -1,12 +1,3 @@
-export * from './api';
-export * from './articles';
-export * from './articles.query';
-export * from './comments';
-export * from './comments.mutation';
-export * from './comments.query';
-export * from './contact';
-export * from './contact.mutation';
-export * from './thematics';
-export * from './thematics.query';
-export * from './topics';
-export * from './topics.query';
+export * from './fetchers';
+export * from './helpers';
+export * from './mutators';
diff --git a/src/services/graphql/mutators/create-comment.ts b/src/services/graphql/mutators/create-comment.ts
new file mode 100644
index 0000000..d9d177d
--- /dev/null
+++ b/src/services/graphql/mutators/create-comment.ts
@@ -0,0 +1,70 @@
+import type { Nullable } from '../../../types';
+import { fetchGraphQL, getGraphQLUrl } from '../../../utils/helpers';
+
+type CreatedComment = {
+ clientMutationId: string;
+ success: boolean;
+ comment: Nullable<{
+ approved: boolean;
+ }>;
+};
+
+type CreateCommentResponse = {
+ createComment: CreatedComment;
+};
+
+export const createCommentMutation = `mutation CreateComment(
+ $author: String!
+ $authorEmail: String!
+ $authorUrl: String!
+ $content: String!
+ $parent: ID = null
+ $commentOn: Int!
+ $clientMutationId: String!
+) {
+ createComment(
+ input: {
+ author: $author
+ authorEmail: $authorEmail
+ authorUrl: $authorUrl
+ content: $content
+ parent: $parent
+ commentOn: $commentOn
+ clientMutationId: $clientMutationId
+ }
+ ) {
+ clientMutationId
+ success
+ comment {
+ approved
+ }
+ }
+}`;
+
+export type CreateCommentInput = {
+ author: string;
+ authorEmail: string;
+ authorUrl: string;
+ clientMutationId: string;
+ commentOn: number;
+ content: string;
+ parent?: number;
+};
+
+/**
+ * Create a new comment using GraphQL API.
+ *
+ * @param {CreateCommentInput} input - The comment data.
+ * @returns {Promise<CreatedComment>} The created comment.
+ */
+export const createComment = async (
+ input: CreateCommentInput
+): Promise<CreatedComment> => {
+ const response = await fetchGraphQL<CreateCommentResponse>({
+ query: createCommentMutation,
+ url: getGraphQLUrl(),
+ variables: { ...input },
+ });
+
+ return response.createComment;
+};
diff --git a/src/services/graphql/mutators/index.ts b/src/services/graphql/mutators/index.ts
new file mode 100644
index 0000000..dfdd511
--- /dev/null
+++ b/src/services/graphql/mutators/index.ts
@@ -0,0 +1,2 @@
+export * from './create-comment';
+export * from './send-email';
diff --git a/src/services/graphql/mutators/send-email.ts b/src/services/graphql/mutators/send-email.ts
new file mode 100644
index 0000000..45b6fca
--- /dev/null
+++ b/src/services/graphql/mutators/send-email.ts
@@ -0,0 +1,49 @@
+import { fetchGraphQL, getGraphQLUrl } from 'src/utils/helpers';
+
+type SentEmail = {
+ clientMutationId: string;
+ message: string;
+ origin: string;
+ replyTo: string;
+ sent: boolean;
+};
+
+type SendEmailResponse = {
+ sendEmail: SentEmail;
+};
+
+const sendMailMutation = `mutation SendEmail($body: String, $clientMutationId: String, $replyTo: String, $subject: String) {
+ sendEmail(
+ input: {body: $body, clientMutationId: $clientMutationId, replyTo: $replyTo, subject: $subject}
+ ) {
+ clientMutationId
+ message
+ origin
+ replyTo
+ sent
+ to
+ }
+}`;
+
+export type SendMailInput = {
+ body: string;
+ clientMutationId: string;
+ replyTo: string;
+ subject: string;
+};
+
+/**
+ * Send an email using GraphQL API.
+ *
+ * @param {SendMailInput} data - The mail data.
+ * @returns {Promise<SentEmail>} The mutation response.
+ */
+export const sendMail = async (data: SendMailInput): Promise<SentEmail> => {
+ const response = await fetchGraphQL<SendEmailResponse>({
+ query: sendMailMutation,
+ url: getGraphQLUrl(),
+ variables: { ...data },
+ });
+
+ return response.sendEmail;
+};
diff --git a/src/services/graphql/thematics.query.ts b/src/services/graphql/thematics.query.ts
deleted file mode 100644
index 5a82133..0000000
--- a/src/services/graphql/thematics.query.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * Query the full thematic data using its slug.
- */
-export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
- thematic(id: $slug, idType: SLUG) {
- acfThematics {
- postsInThematic {
- ... on Post {
- acfPosts {
- postsInTopic {
- ... on Topic {
- databaseId
- slug
- title
- }
- }
- }
- commentCount
- contentParts {
- beforeMore
- }
- databaseId
- date
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- info {
- wordsCount
- }
- modified
- slug
- title
- }
- }
- }
- contentParts {
- afterMore
- beforeMore
- }
- databaseId
- date
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- info {
- wordsCount
- }
- modified
- seo {
- metaDesc
- title
- }
- slug
- title
- }
-}`;
-
-/**
- * Query an array of partial thematics.
- */
-export const thematicsListQuery = `query ThematicsList($after: String = "", $first: Int = 10) {
- thematics(
- after: $after
- first: $first
- where: {orderby: {field: TITLE, order: ASC}, status: PUBLISH}
- ) {
- edges {
- cursor
- node {
- databaseId
- slug
- title
- }
- }
- pageInfo {
- endCursor
- hasNextPage
- total
- }
- }
-}`;
-
-/**
- * Query an array of thematics slug.
- */
-export const thematicsSlugQuery = `query ThematicsSlug($first: Int = 10, $after: String = "") {
- thematics(after: $after, first: $first) {
- edges {
- cursor
- node {
- slug
- }
- }
- pageInfo {
- total
- }
- }
-}`;
-
-/**
- * Query the total number of thematics.
- */
-export const totalThematicsQuery = `query ThematicsTotal {
- thematics {
- pageInfo {
- total
- }
- }
-}`;
diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts
deleted file mode 100644
index c02a42c..0000000
--- a/src/services/graphql/thematics.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import type {
- EdgesResponse,
- GraphQLEdgesInput,
- PageLink,
- RawArticle,
- RawThematic,
- RawThematicPreview,
- Slug,
- Thematic,
- TotalItems,
-} from '../../types';
-import {
- getImageFromRawData,
- getPageLinkFromRawData,
- sortPageLinksByName,
-} from '../../utils/helpers';
-import { fetchAPI } from './api';
-import { getArticleFromRawData } from './articles';
-import {
- thematicBySlugQuery,
- thematicsListQuery,
- thematicsSlugQuery,
- totalThematicsQuery,
-} from './thematics.query';
-
-/**
- * Retrieve the total number of thematics.
- *
- * @returns {Promise<number>} - The thematics total number.
- */
-export const getTotalThematics = async (): Promise<number> => {
- const response = await fetchAPI<TotalItems, typeof totalThematicsQuery>({
- query: totalThematicsQuery,
- });
-
- return response.thematics.pageInfo.total;
-};
-
-/**
- * Retrieve the given number of thematics from API.
- *
- * @param {GraphQLEdgesInput} props - An object of GraphQL variables.
- * @returns {Promise<EdgesResponse<RawThematicPreview>>} The thematics data.
- */
-export const getThematicsPreview = async (
- props: GraphQLEdgesInput
-): Promise<EdgesResponse<RawThematicPreview>> => {
- const response = await fetchAPI<
- RawThematicPreview,
- typeof thematicsListQuery
- >({ query: thematicsListQuery, variables: props });
-
- return response.thematics;
-};
-
-/**
- * Convert raw data to an Thematic object.
- *
- * @param {RawThematic} data - The page raw data.
- * @returns {Thematic} The page data.
- */
-export const getThematicFromRawData = async (
- data: RawThematic
-): Promise<Thematic> => {
- const {
- acfThematics,
- contentParts,
- databaseId,
- date,
- featuredImage,
- info,
- modified,
- slug,
- title,
- seo,
- } = data;
-
- /**
- * Retrieve an array of related topics.
- *
- * @param posts - The thematic posts.
- * @returns {PageLink[]} An array of topics links.
- */
- const getRelatedTopics = (posts: RawArticle[]): PageLink[] => {
- const topics: PageLink[] = [];
-
- posts.forEach((post) => {
- if (post.acfPosts.postsInTopic) {
- for (const topic of post.acfPosts.postsInTopic) {
- topics.push(getPageLinkFromRawData(topic, 'topic'));
- }
- }
- });
-
- const topicsIds = topics.map((topic) => topic.id);
- const uniqueTopics = topics.filter(
- ({ id }, index) => !topicsIds.includes(id, index + 1)
- );
-
- return uniqueTopics.sort(sortPageLinksByName);
- };
-
- return {
- content: contentParts.afterMore,
- id: databaseId,
- intro: contentParts.beforeMore,
- meta: {
- articles: await Promise.all(
- acfThematics.postsInThematic.map(async (post) =>
- getArticleFromRawData(post)
- )
- ),
- cover: featuredImage?.node
- ? getImageFromRawData(featuredImage.node)
- : undefined,
- dates: { publication: date, update: modified },
- seo: {
- description: seo?.metaDesc ?? '',
- title: seo?.title ?? '',
- },
- topics: getRelatedTopics(acfThematics.postsInThematic),
- wordsCount: info.wordsCount,
- },
- slug,
- title,
- };
-};
-
-/**
- * Retrieve a Thematic object by slug.
- *
- * @param {string} slug - The thematic slug.
- * @returns {Promise<Article>} The requested thematic.
- */
-export const getThematicBySlug = async (slug: string): Promise<Thematic> => {
- const response = await fetchAPI<RawThematic, typeof thematicBySlugQuery>({
- query: thematicBySlugQuery,
- variables: { slug },
- });
-
- return getThematicFromRawData(response.thematic);
-};
-
-/**
- * Retrieve all the thematics slugs.
- *
- * @returns {Promise<string[]>} - An array of thematics slugs.
- */
-export const getAllThematicsSlugs = async (): Promise<string[]> => {
- const totalThematics = await getTotalThematics();
- const response = await fetchAPI<Slug, typeof thematicsSlugQuery>({
- query: thematicsSlugQuery,
- variables: { first: totalThematics },
- });
-
- return response.thematics.edges.map((edge) => edge.node.slug);
-};
diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts
deleted file mode 100644
index 57b2569..0000000
--- a/src/services/graphql/topics.query.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Query the full topic data using its slug.
- */
-export const topicBySlugQuery = `query TopicBy($slug: ID!) {
- topic(id: $slug, idType: SLUG) {
- acfTopics {
- officialWebsite
- postsInTopic {
- ... on Post {
- acfPosts {
- postsInThematic {
- ... on Thematic {
- databaseId
- slug
- title
- }
- }
- }
- commentCount
- contentParts {
- beforeMore
- }
- databaseId
- date
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- info {
- wordsCount
- }
- modified
- slug
- title
- }
- }
- }
- contentParts {
- afterMore
- beforeMore
- }
- databaseId
- date
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- info {
- wordsCount
- }
- modified
- seo {
- metaDesc
- title
- }
- slug
- title
- }
-}`;
-
-/**
- * Query an array of partial topics.
- */
-export const topicsListQuery = `query TopicsList($after: String = "", $first: Int = 10) {
- topics(
- after: $after
- first: $first
- where: {orderby: {field: TITLE, order: ASC}, status: PUBLISH}
- ) {
- edges {
- cursor
- node {
- databaseId
- featuredImage {
- node {
- altText
- mediaDetails {
- height
- width
- }
- sourceUrl
- title
- }
- }
- slug
- title
- }
- }
- pageInfo {
- endCursor
- hasNextPage
- total
- }
- }
-}`;
-
-/**
- * Query an array of topics slug.
- */
-export const topicsSlugQuery = `query TopicsSlug($first: Int = 10, $after: String = "") {
- topics(after: $after, first: $first) {
- edges {
- cursor
- node {
- slug
- }
- }
- pageInfo {
- total
- }
- }
-}`;
-
-/**
- * Query the total number of topics.
- */
-export const totalTopicsQuery = `query TopicsTotal {
- topics {
- pageInfo {
- total
- }
- }
-}`;
diff --git a/src/services/graphql/topics.ts b/src/services/graphql/topics.ts
deleted file mode 100644
index d8a9b6a..0000000
--- a/src/services/graphql/topics.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import type {
- EdgesResponse,
- GraphQLEdgesInput,
- PageLink,
- RawArticle,
- RawTopic,
- RawTopicPreview,
- Slug,
- Topic,
- TotalItems,
-} from '../../types';
-import {
- getImageFromRawData,
- getPageLinkFromRawData,
- sortPageLinksByName,
-} from '../../utils/helpers';
-import { fetchAPI } from './api';
-import { getArticleFromRawData } from './articles';
-import {
- topicBySlugQuery,
- topicsListQuery,
- topicsSlugQuery,
- totalTopicsQuery,
-} from './topics.query';
-
-/**
- * Retrieve the total number of topics.
- *
- * @returns {Promise<number>} - The topics total number.
- */
-export const getTotalTopics = async (): Promise<number> => {
- const response = await fetchAPI<TotalItems, typeof totalTopicsQuery>({
- query: totalTopicsQuery,
- });
-
- return response.topics.pageInfo.total;
-};
-
-/**
- * Retrieve the given number of topics from API.
- *
- * @param {GraphQLEdgesInput} props - An object of GraphQL variables.
- * @returns {Promise<EdgesResponse<RawTopicPreview>>} The topics data.
- */
-export const getTopicsPreview = async (
- props: GraphQLEdgesInput
-): Promise<EdgesResponse<RawTopicPreview>> => {
- const response = await fetchAPI<RawTopicPreview, typeof topicsListQuery>({
- query: topicsListQuery,
- variables: props,
- });
-
- return response.topics;
-};
-
-/**
- * Convert raw data to a Topic object.
- *
- * @param {RawTopic} data - The page raw data.
- * @returns {Topic} The page data.
- */
-export const getTopicFromRawData = async (data: RawTopic): Promise<Topic> => {
- const {
- acfTopics,
- contentParts,
- databaseId,
- date,
- featuredImage,
- info,
- modified,
- slug,
- title,
- seo,
- } = data;
-
- /**
- * Retrieve an array of related topics.
- *
- * @param posts - The topic posts.
- * @returns {PageLink[]} An array of topics links.
- */
- const getRelatedThematics = (posts: RawArticle[]): PageLink[] => {
- const thematics: PageLink[] = [];
-
- posts.forEach((post) => {
- if (post.acfPosts.postsInThematic) {
- for (const thematic of post.acfPosts.postsInThematic) {
- thematics.push(getPageLinkFromRawData(thematic, 'thematic'));
- }
- }
- });
-
- const thematicsIds = thematics.map((thematic) => thematic.id);
- const uniqueThematics = thematics.filter(
- ({ id }, index) => !thematicsIds.includes(id, index + 1)
- );
-
- return uniqueThematics.sort(sortPageLinksByName);
- };
-
- return {
- content: contentParts.afterMore,
- id: databaseId,
- intro: contentParts.beforeMore,
- meta: {
- articles: await Promise.all(
- acfTopics.postsInTopic.map(async (post) => getArticleFromRawData(post))
- ),
- cover: featuredImage?.node
- ? getImageFromRawData(featuredImage.node)
- : undefined,
- dates: { publication: date, update: modified },
- website: acfTopics.officialWebsite,
- seo: {
- description: seo?.metaDesc ?? '',
- title: seo?.title ?? '',
- },
- thematics: getRelatedThematics(acfTopics.postsInTopic),
- wordsCount: info.wordsCount,
- },
- slug,
- title,
- };
-};
-
-/**
- * Retrieve a Topic object by slug.
- *
- * @param {string} slug - The topic slug.
- * @returns {Promise<Article>} The requested topic.
- */
-export const getTopicBySlug = async (slug: string): Promise<Topic> => {
- const response = await fetchAPI<RawTopic, typeof topicBySlugQuery>({
- query: topicBySlugQuery,
- variables: { slug },
- });
-
- return getTopicFromRawData(response.topic);
-};
-
-/**
- * Retrieve all the topics slugs.
- *
- * @returns {Promise<string[]>} - An array of topics slugs.
- */
-export const getAllTopicsSlugs = async (): Promise<string[]> => {
- const totalTopics = await getTotalTopics();
- const response = await fetchAPI<Slug, typeof topicsSlugQuery>({
- query: topicsSlugQuery,
- variables: { first: totalTopics },
- });
-
- return response.topics.edges.map((edge) => edge.node.slug);
-};
diff --git a/src/types/app.ts b/src/types/app.ts
index b613e6e..218d63b 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -28,108 +28,6 @@ export type AppPropsWithLayout = AppProps<CustomPageProps> & {
Component: NextPageWithLayout;
};
-export type ContentKind =
- | 'article'
- | 'comment'
- | 'page'
- | 'project'
- | 'thematic'
- | 'topic';
-
-export type Author<T extends ContentKind> = {
- avatar?: Image;
- description?: T extends 'comment' ? never : string;
- name: string;
- website?: string;
-};
-
-export type CommentMeta = {
- author: Author<'comment'>;
- date: string;
-};
-
-export type SingleComment = {
- approved: boolean;
- content: string;
- id: number;
- meta: CommentMeta;
- parentId?: number;
- replies: SingleComment[];
-};
-
-export type Dates = {
- publication: string;
- update?: string;
-};
-
-export type Image = {
- alt: string;
- height: number;
- src: string;
- title?: string;
- width: number;
-};
-
-export type Repos = {
- github?: string;
- gitlab?: string;
-};
-
-export type SEO = {
- description: string;
- title: string;
-};
-
-export type PageKind = Exclude<ContentKind, 'comment'>;
-
-export type Meta<T extends PageKind> = {
- articles?: T extends 'thematic' | 'topic' ? Article[] : never;
- author?: T extends 'article' | 'page' ? Author<T> : never;
- commentsCount?: T extends 'article' ? number : never;
- cover?: Image;
- dates: Dates;
- license?: T extends 'project' ? string : never;
- repos?: T extends 'project' ? Repos : never;
- seo: SEO;
- tagline?: T extends 'project' ? string : never;
- technologies?: T extends 'project' ? string[] : never;
- thematics?: T extends 'article' | 'topic' ? PageLink[] : never;
- topics?: T extends 'article' | 'thematic' ? PageLink[] : never;
- website?: T extends 'topic' ? string : never;
- wordsCount: number;
-};
-
-export type Page<T extends PageKind> = {
- content: string;
- id: number | string;
- intro: string;
- meta: Meta<T>;
- slug: string;
- title: string;
-};
-
-export type PageLink = {
- id: number;
- logo?: Image;
- name: string;
- url: string;
-};
-
-export type Article = Page<'article'>;
-export type ArticleCard = Pick<Article, 'id' | 'slug' | 'title'> &
- Pick<Meta<'article'>, 'cover' | 'dates'>;
-export type Project = Page<'project'>;
-export type ProjectPreview = Omit<Page<'project'>, 'content'>;
-export type ProjectCard = Pick<Page<'project'>, 'id' | 'slug' | 'title'> & {
- meta: Pick<Meta<'project'>, 'cover' | 'dates' | 'tagline' | 'technologies'>;
-};
-export type Thematic = Page<'thematic'>;
-export type Topic = Page<'topic'>;
-
-export type Slug = {
- slug: string;
-};
-
export type Position = 'bottom' | 'center' | 'left' | 'right' | 'top';
/** Spacing keys defined has CSS variables */
diff --git a/src/types/data.ts b/src/types/data.ts
new file mode 100644
index 0000000..9a6d674
--- /dev/null
+++ b/src/types/data.ts
@@ -0,0 +1,289 @@
+import type { StaticImageData } from 'next/image';
+import type { Nullable } from './generics';
+import type { GraphQLNode } from './gql';
+
+export type SlugNode = {
+ slug: string;
+};
+
+//===========================================================================
+// Data from WordPress
+//===========================================================================
+
+type WPSeo = {
+ metaDesc: string;
+ title: string;
+};
+
+type WPCommentAuthorAvatar = {
+ height: number;
+ url: string;
+ width: number;
+};
+
+type WPCommentAuthor = {
+ avatar: Nullable<WPCommentAuthorAvatar>;
+ name: string;
+ url: Nullable<string>;
+};
+
+export type WPCommentStatus = 'APPROVE' | 'HOLD' | 'SPAM' | 'TRASH';
+
+export type WPComment = {
+ approved: boolean;
+ author: GraphQLNode<WPCommentAuthor>;
+ content: string;
+ databaseId: number;
+ date: string;
+ parentDatabaseId: number;
+ status: WPCommentStatus;
+};
+
+type WPContentParts = {
+ afterMore: string;
+ beforeMore: string;
+};
+
+export type WPImage = {
+ altText: Nullable<string>;
+ mediaDetails: {
+ height: number;
+ width: number;
+ };
+ sourceUrl: string;
+ title: Nullable<string>;
+};
+
+type WPInfo = { wordsCount: number };
+
+type WPContent = {
+ date: string;
+ featuredImage: Nullable<GraphQLNode<WPImage>>;
+ modified: string;
+ seo: WPSeo;
+ slug: string;
+ title: string;
+};
+
+export type WPPage = WPContent & {
+ contentParts: WPContentParts;
+ info: WPInfo;
+};
+
+type WPPostAuthor = { name: string };
+
+type WPAcfPosts = {
+ postsInThematic: Nullable<WPThematicPreview[]>;
+ postsInTopic: Nullable<WPTopicPreview[]>;
+};
+
+export type WPPost = WPContent & {
+ acfPosts: Nullable<Partial<WPAcfPosts>>;
+ author: GraphQLNode<WPPostAuthor>;
+ commentCount: Nullable<number>;
+ contentParts: WPContentParts;
+ databaseId: number;
+ info: WPInfo;
+};
+
+export type WPPostPreview = Pick<
+ WPPost,
+ | 'commentCount'
+ | 'databaseId'
+ | 'date'
+ | 'featuredImage'
+ | 'info'
+ | 'modified'
+ | 'slug'
+ | 'title'
+> & {
+ acfPosts:
+ | Nullable<Pick<WPAcfPosts, 'postsInThematic'>>
+ | Nullable<Pick<WPAcfPosts, 'postsInTopic'>>;
+ contentParts: Pick<WPContentParts, 'beforeMore'>;
+};
+
+export type RecentWPPost = Pick<
+ WPPost,
+ 'date' | 'featuredImage' | 'slug' | 'title'
+> & {
+ databaseId: number;
+};
+
+type WPAcfThematics = {
+ postsInThematic: Nullable<WPPostPreview[]>;
+};
+
+export type WPThematic = WPContent & {
+ acfThematics: Nullable<WPAcfThematics>;
+};
+
+export type WPThematicPreview = Pick<WPThematic, 'slug' | 'title'> & {
+ databaseId: number;
+};
+
+type WPAcfTopics = {
+ officialWebsite: string;
+ postsInTopic: Nullable<WPPostPreview[]>;
+};
+
+export type WPTopic = WPContent & {
+ acfTopics: Nullable<WPAcfTopics>;
+};
+
+export type WPTopicPreview = Pick<
+ WPTopic,
+ 'featuredImage' | 'slug' | 'title'
+> & {
+ databaseId: number;
+};
+
+//===========================================================================
+// Data from MDX files
+//===========================================================================
+
+export type MDXData = {
+ file: string;
+ image: MDXImage;
+};
+
+export type MDXImage = StaticImageData & {
+ alt: string;
+ title?: string;
+};
+
+export type MDXPageMeta = Omit<PageMeta, 'wordsCount'> & {
+ intro: string;
+ title: string;
+};
+
+export type MDXProjectMeta = Omit<ProjectMeta, 'wordsCount'> & {
+ intro: string;
+ title: string;
+};
+
+//===========================================================================
+// Data used in this application
+//===========================================================================
+
+export type Dates = {
+ publication: string;
+ update?: string;
+};
+
+export type SEO = {
+ description: string;
+ title: string;
+};
+
+export type Img = {
+ alt: string;
+ height: number;
+ src: string;
+ title?: string;
+ width: number;
+};
+
+export type CommentAuthor = {
+ avatar?: Omit<Img, 'title'>;
+ name: string;
+ website?: string;
+};
+
+export type CommentMeta = {
+ author: CommentAuthor;
+ date: string;
+};
+
+export type SingleComment = {
+ content: string;
+ id: number;
+ isApproved: boolean;
+ meta: CommentMeta;
+ parentId?: number;
+ replies: SingleComment[];
+};
+
+export type PageMeta = {
+ cover?: Img;
+ dates: Dates;
+ seo: SEO;
+ wordsCount: number;
+};
+
+export type Page = {
+ content: string;
+ intro: string;
+ slug: string;
+ title: string;
+};
+
+export type PageLink = {
+ id: number;
+ logo?: Img;
+ name: string;
+ url: string;
+};
+
+type ArticleMeta = PageMeta & {
+ author?: string;
+ commentsCount?: number;
+ thematics?: PageLink[];
+ topics?: PageLink[];
+};
+
+export type Article = Page & {
+ id: number;
+ meta: ArticleMeta;
+};
+
+export type ArticlePreview = Pick<Article, 'intro' | 'slug' | 'title'> & {
+ id: number;
+ meta: Omit<ArticleMeta, 'author' | 'seo' | 'topics'>;
+};
+
+export type RecentArticle = Pick<Article, 'slug' | 'title'> &
+ Pick<ArticleMeta, 'cover'> & {
+ id: number;
+ publicationDate: string;
+ };
+
+export type Repos = {
+ github?: string;
+ gitlab?: string;
+};
+
+export type ProjectMeta = Omit<PageMeta, 'wordsCount'> & {
+ license?: string;
+ repos?: Repos;
+ tagline?: string;
+ technologies?: string[];
+};
+
+export type Project = Omit<Page, 'content'> & {
+ id: string;
+ meta: ProjectMeta;
+};
+
+export type ProjectPreview = Omit<Project, 'meta'> & {
+ meta: Omit<ProjectMeta, 'license' | 'repos'>;
+};
+
+export type ThematicMeta = PageMeta & {
+ articles?: ArticlePreview[];
+ topics?: PageLink[];
+};
+
+export type Thematic = Page & {
+ meta: ThematicMeta;
+};
+
+export type TopicMeta = PageMeta & {
+ articles?: ArticlePreview[];
+ thematics?: PageLink[];
+ website?: string;
+};
+
+export type Topic = Page & {
+ meta: TopicMeta;
+};
diff --git a/src/types/gql.ts b/src/types/gql.ts
new file mode 100644
index 0000000..cec66c6
--- /dev/null
+++ b/src/types/gql.ts
@@ -0,0 +1,73 @@
+import type { WPCommentStatus } from './data';
+import type { Nullable } from './generics';
+
+export type GraphQLNode<T> = {
+ node: T;
+};
+
+export type GraphQLNodes<T> = {
+ nodes: T[];
+};
+
+export type GraphQLPageInfo = {
+ endCursor: Nullable<string>;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+ startCursor: Nullable<string>;
+ total: number;
+};
+
+export type GraphQLEdge<T> = GraphQLNode<T> & {
+ cursor: string;
+};
+
+export type GraphQLConnection<T> = {
+ edges: GraphQLEdge<T>[];
+ pageInfo: GraphQLPageInfo;
+};
+
+export type GraphQLEdgesInput = {
+ after?: Nullable<string>;
+ before?: Nullable<string>;
+ first?: number;
+ last?: number;
+};
+
+export type GraphQLOrder = 'ASC' | 'DESC';
+
+export type GraphQLCommentWhere = {
+ contentId?: number;
+ contentName?: string;
+ status?: WPCommentStatus;
+};
+
+type GraphQLPostFieldOrder =
+ | 'AUTHOR'
+ | 'COMMENT_COUNT'
+ | 'DATE'
+ | 'MODIFIED'
+ | 'SLUG'
+ | 'TITLE';
+
+export type GraphQLPostOrderBy = {
+ field: GraphQLPostFieldOrder;
+ order: GraphQLOrder;
+};
+
+export type GraphQLPostWhere = {
+ authorName?: string;
+ search?: string;
+ title?: string;
+};
+
+export type GraphQLTaxonomyFieldOrder = 'DATE' | 'MODIFIED' | 'SLUG' | 'TITLE';
+
+export type GraphQLTaxonomyOrderBy = {
+ field: GraphQLTaxonomyFieldOrder;
+ order: GraphQLOrder;
+};
+
+export type GraphQLTaxonomyWhere = {
+ search?: string;
+ title?: string;
+};
diff --git a/src/types/graphql/generics.ts b/src/types/graphql/generics.ts
deleted file mode 100644
index dec5f10..0000000
--- a/src/types/graphql/generics.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export type GraphQLPageInfo = {
- endCursor: string;
- hasNextPage: boolean;
- total: number;
-};
-
-export type GraphQLEdges<T> = {
- cursor: string;
- node: T;
-};
-
-export type GraphQLEdgesInput = {
- after?: string;
- before?: string;
- first?: number;
- last?: number;
-};
-
-export type GraphQLNode<T> = {
- node: T;
-};
-
-export type GraphQLNodes<T> = {
- nodes: T[];
-};
diff --git a/src/types/graphql/index.ts b/src/types/graphql/index.ts
deleted file mode 100644
index 79eb05e..0000000
--- a/src/types/graphql/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './generics';
-export * from './mutations';
-export * from './queries';
diff --git a/src/types/graphql/mutations.ts b/src/types/graphql/mutations.ts
deleted file mode 100644
index 6ff066c..0000000
--- a/src/types/graphql/mutations.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { sendCommentMutation, sendMailMutation } from '../../services/graphql';
-
-//===========================================================================
-// Existing mutations list
-//===========================================================================
-
-export type Mutations = typeof sendMailMutation | typeof sendCommentMutation;
-
-//===========================================================================
-// Mutations response types
-//===========================================================================
-
-export type SendCommentResponse<T> = {
- createComment: T;
-};
-
-export type SendMailResponse<T> = {
- sendEmail: T;
-};
-
-export type MutationsResponseMap<T> = {
- [sendCommentMutation]: SendCommentResponse<T>;
- [sendMailMutation]: SendMailResponse<T>;
-};
-
-export type Approved = {
- approved: boolean;
-};
-
-export type SentComment = {
- clientMutationId: string;
- success: boolean;
- comment: Approved | null;
-};
-
-//===========================================================================
-// Mutations input types
-//===========================================================================
-
-export type SendCommentInput = {
- author: string;
- authorEmail: string;
- authorUrl: string;
- clientMutationId: string;
- commentOn: number;
- content: string;
- parent?: number;
-};
-
-export type SendMailInput = {
- body: string;
- clientMutationId: string;
- replyTo: string;
- subject: string;
-};
-
-export type MutationsInputMap = {
- [sendCommentMutation]: SendCommentInput;
- [sendMailMutation]: SendMailInput;
-};
diff --git a/src/types/graphql/queries.ts b/src/types/graphql/queries.ts
deleted file mode 100644
index 83e9c67..0000000
--- a/src/types/graphql/queries.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import {
- articleBySlugQuery,
- articlesCardQuery,
- articlesEndCursorQuery,
- articlesQuery,
- articlesSlugQuery,
- commentsQuery,
- thematicBySlugQuery,
- thematicsListQuery,
- thematicsSlugQuery,
- topicBySlugQuery,
- topicsListQuery,
- topicsSlugQuery,
- totalArticlesQuery,
- totalThematicsQuery,
- totalTopicsQuery,
-} from '../../services/graphql';
-import { Slug } from '../app';
-import { RawComment } from '../raw-data';
-import {
- GraphQLEdges,
- GraphQLEdgesInput,
- GraphQLNodes,
- GraphQLPageInfo,
-} from './generics';
-
-//===========================================================================
-// Existing queries list
-//===========================================================================
-
-export type Queries =
- | typeof articlesQuery
- | typeof articleBySlugQuery
- | typeof articlesCardQuery
- | typeof articlesEndCursorQuery
- | typeof articlesSlugQuery
- | typeof commentsQuery
- | typeof thematicBySlugQuery
- | typeof thematicsListQuery
- | typeof thematicsSlugQuery
- | typeof topicBySlugQuery
- | typeof topicsListQuery
- | typeof topicsSlugQuery
- | typeof totalArticlesQuery
- | typeof totalThematicsQuery
- | typeof totalTopicsQuery;
-
-//===========================================================================
-// Queries response types
-//===========================================================================
-
-export type ArticleResponse<T> = {
- post: T;
-};
-
-export type ArticlesResponse<T> = {
- posts: T;
-};
-
-export type CommentsResponse<T> = {
- comments: T;
-};
-
-export type ThematicResponse<T> = {
- thematic: T;
-};
-
-export type ThematicsResponse<T> = {
- thematics: T;
-};
-
-export type TopicResponse<T> = {
- topic: T;
-};
-
-export type TopicsResponse<T> = {
- topics: T;
-};
-
-export type EdgesResponse<T> = {
- edges: GraphQLEdges<T>[];
- pageInfo: GraphQLPageInfo;
-};
-
-export type EndCursorResponse = {
- pageInfo: Pick<GraphQLPageInfo, 'endCursor'>;
-};
-
-export type QueriesResponseMap<T> = {
- [articleBySlugQuery]: ArticleResponse<T>;
- [articlesCardQuery]: ArticlesResponse<GraphQLNodes<T>>;
- [articlesEndCursorQuery]: ArticlesResponse<EndCursorResponse>;
- [articlesQuery]: ArticlesResponse<EdgesResponse<T>>;
- [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>;
- [commentsQuery]: CommentsResponse<EdgesResponse<T>>;
- [thematicBySlugQuery]: ThematicResponse<T>;
- [thematicsListQuery]: ThematicsResponse<EdgesResponse<T>>;
- [thematicsSlugQuery]: ThematicsResponse<EdgesResponse<T>>;
- [topicBySlugQuery]: TopicResponse<T>;
- [topicsListQuery]: TopicsResponse<EdgesResponse<T>>;
- [topicsSlugQuery]: TopicsResponse<EdgesResponse<T>>;
- [totalArticlesQuery]: ArticlesResponse<T>;
- [totalThematicsQuery]: ThematicsResponse<T>;
- [totalTopicsQuery]: TopicsResponse<T>;
-};
-
-//===========================================================================
-// Queries input types
-//===========================================================================
-
-export type QueryEdges = Pick<GraphQLEdgesInput, 'after' | 'first'>;
-
-export type ContentId = {
- contentId: number;
-};
-
-export type Search = {
- search?: string;
-};
-
-export type QueriesInputMap = {
- [articleBySlugQuery]: Slug;
- [articlesCardQuery]: QueryEdges & Search;
- [articlesEndCursorQuery]: QueryEdges & Search;
- [articlesQuery]: QueryEdges & Search;
- [articlesSlugQuery]: QueryEdges & Search;
- [commentsQuery]: ContentId & QueryEdges;
- [thematicBySlugQuery]: Slug;
- [thematicsListQuery]: QueryEdges & Search;
- [thematicsSlugQuery]: QueryEdges & Search;
- [topicBySlugQuery]: Slug;
- [topicsListQuery]: QueryEdges & Search;
- [topicsSlugQuery]: QueryEdges & Search;
- [totalArticlesQuery]: Search;
- [totalThematicsQuery]: null;
- [totalTopicsQuery]: null;
-};
-
-export type CommentPage = {
- comments: RawComment[];
- hasNextPage: boolean;
- endCursor: string;
-};
diff --git a/src/types/index.ts b/src/types/index.ts
index e2f0f55..d6e4a6a 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,6 +1,5 @@
export * from './app';
+export * from './data';
export * from './generics';
-export * from './graphql';
-export * from './mdx';
-export * from './raw-data';
+export * from './gql';
export * from './swr';
diff --git a/src/types/mdx.ts b/src/types/mdx.ts
deleted file mode 100644
index 7645ce6..0000000
--- a/src/types/mdx.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { StaticImageData } from 'next/image';
-import { Meta } from './app';
-
-export type MDXData = {
- file: string;
- image: MDXImage;
-};
-
-export type MDXImage = StaticImageData & {
- alt: string;
- title?: string;
-};
-
-export type MDXPageMeta = Pick<Meta<'page'>, 'cover' | 'dates' | 'seo'> & {
- intro: string;
- title: string;
-};
-
-export type MDXProjectMeta = Exclude<Meta<'project'>, 'wordsCount'> & {
- intro: string;
- title: string;
-};
diff --git a/src/types/raw-data.ts b/src/types/raw-data.ts
deleted file mode 100644
index 022016e..0000000
--- a/src/types/raw-data.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Types for raw data coming from GraphQL API.
- */
-
-import { ContentKind } from './app';
-import { GraphQLNode, GraphQLPageInfo } from './graphql/generics';
-
-export type ACFPosts = {
- postsInThematic?: RawThematicPreview[];
- postsInTopic?: RawTopicPreview[];
-};
-
-export type ACFThematics = {
- postsInThematic: RawArticle[];
-};
-
-export type ACFTopics = {
- officialWebsite: string;
- postsInTopic: RawArticle[];
-};
-
-export type ContentParts = {
- afterMore: string;
- beforeMore: string;
-};
-
-export type Info = {
- wordsCount: number;
-};
-
-export type RawAuthor<T extends ContentKind> = {
- description?: T extends 'comment' ? never : string;
- gravatarUrl?: string;
- name: string;
- url?: string;
-};
-
-export type RawComment = {
- approved: boolean;
- author: GraphQLNode<RawAuthor<'comment'>>;
- content: string;
- databaseId: number;
- date: string;
- parentDatabaseId: number;
-};
-
-export type RawCommentsPage = {
- comments: RawComment[];
- hasNextPage: boolean;
- endCursor: string;
-};
-
-export type RawCover = {
- altText: string;
- mediaDetails: {
- width: number;
- height: number;
- };
- sourceUrl: string;
- title?: string;
-};
-
-export type RawArticle = RawPage & {
- acfPosts: ACFPosts;
- commentCount: number | null;
-};
-
-export type RawArticlePreview = Pick<
- RawArticle,
- 'databaseId' | 'date' | 'featuredImage' | 'slug' | 'title'
->;
-
-export type RawPage = {
- author?: GraphQLNode<RawAuthor<'page'>>;
- contentParts: ContentParts;
- databaseId: number;
- date: string;
- featuredImage: GraphQLNode<RawCover> | null;
- info: Info;
- modified: string;
- seo?: RawSEO;
- slug: string;
- title: string;
-};
-
-export type RawSEO = {
- metaDesc: string;
- title: string;
-};
-
-export type RawThematic = RawPage & {
- acfThematics: ACFThematics;
-};
-
-export type RawThematicPreview = Pick<
- RawThematic,
- 'databaseId' | 'featuredImage' | 'slug' | 'title'
->;
-
-export type RawTopic = RawPage & {
- acfTopics: ACFTopics;
-};
-
-export type RawTopicPreview = Pick<
- RawTopic,
- 'databaseId' | 'featuredImage' | 'slug' | 'title'
->;
-
-export type TotalItems = {
- pageInfo: Pick<GraphQLPageInfo, 'total'>;
-};
diff --git a/src/utils/helpers/author.ts b/src/utils/helpers/author.ts
deleted file mode 100644
index a5e9bc6..0000000
--- a/src/utils/helpers/author.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { type Author, type ContentKind, type RawAuthor } from '../../types';
-
-/**
- * Convert author raw data to regular data.
- *
- * @param {RawAuthor<ContentKind>} data - The author raw data.
- * @param {ContentKind} kind - The author kind. Either `page` or `comment`.
- * @param {number} [avatarSize] - The author avatar size.
- * @returns {Author<ContentKind>} The author data.
- */
-export const getAuthorFromRawData = (
- data: RawAuthor<typeof kind>,
- kind: ContentKind,
- avatarSize: number = 80
-): Author<typeof kind> => {
- const { name, description, gravatarUrl, url } = data;
-
- return {
- name,
- avatar: gravatarUrl
- ? {
- alt: `${name} avatar`,
- height: avatarSize,
- src: gravatarUrl,
- width: avatarSize,
- }
- : undefined,
- description,
- website: url,
- };
-};
diff --git a/src/utils/helpers/graphql.ts b/src/utils/helpers/graphql.ts
new file mode 100644
index 0000000..e07b151
--- /dev/null
+++ b/src/utils/helpers/graphql.ts
@@ -0,0 +1,64 @@
+import type { Nullable } from '../../types';
+import { CONFIG } from '../config';
+
+/**
+ * Retrieve the API url from settings.
+ *
+ * @returns {string} The API url.
+ */
+export const getGraphQLUrl = (): string => {
+ if (!CONFIG.api.url) throw new Error('You forgot to define the API url.');
+
+ return CONFIG.api.url;
+};
+
+export type GraphQLData<T> = Record<string, Nullable<T>>;
+
+type GraphQLResponse<T extends GraphQLData<unknown>> = {
+ data: T;
+ errors?: { message: string }[];
+};
+
+export type FetchGraphQLConfig = {
+ query: string;
+ url: string;
+ variables?: Record<string, unknown>;
+};
+
+/**
+ * Retrieve GraphQL data using fetch.
+ *
+ * @template T - The expected data type.
+ * @param {FetchGraphQLConfig} config - A configuration object.
+ * @returns {Promise<T>} The data.
+ */
+export const fetchGraphQL = async <
+ T extends GraphQLData<unknown> = GraphQLData<unknown>,
+>({
+ query,
+ url,
+ variables,
+}: FetchGraphQLConfig): Promise<T> => {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json;charset=UTF-8',
+ },
+ body: JSON.stringify({
+ query,
+ variables,
+ }),
+ });
+
+ const { data, errors }: GraphQLResponse<T> = await response.json();
+
+ if (!response.ok) {
+ const error = new Error(
+ errors?.map((e) => e.message).join('\n') ?? 'Network response was not OK'
+ );
+
+ return Promise.reject(error);
+ }
+
+ return data;
+};
diff --git a/src/utils/helpers/images.ts b/src/utils/helpers/images.ts
deleted file mode 100644
index 6e0c2c5..0000000
--- a/src/utils/helpers/images.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { type Image, type RawCover } from '../../types';
-
-/**
- * Retrieve an Image object from raw data.
- *
- * @param image - The cover raw data.
- * @returns {Image} - An Image object.
- */
-export const getImageFromRawData = (image: RawCover): Image => {
- return {
- alt: image.altText,
- height: image.mediaDetails.height,
- src: image.sourceUrl,
- title: image.title,
- width: image.mediaDetails.width,
- };
-};
diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts
index 92f9424..94fde45 100644
--- a/src/utils/helpers/index.ts
+++ b/src/utils/helpers/index.ts
@@ -1,5 +1,4 @@
-export * from './author';
-export * from './images';
+export * from './graphql';
export * from './pages';
export * from './reading-time';
export * from './refs';
diff --git a/src/utils/helpers/pages.tsx b/src/utils/helpers/pages.tsx
index 7b6bdca..9e015db 100644
--- a/src/utils/helpers/pages.tsx
+++ b/src/utils/helpers/pages.tsx
@@ -1,43 +1,7 @@
import NextImage from 'next/image';
import type { LinksWidgetItemData, PostData } from '../../components';
-import { getArticleFromRawData } from '../../services/graphql';
-import type {
- Article,
- EdgesResponse,
- PageLink,
- RawArticle,
- RawThematicPreview,
- RawTopicPreview,
-} from '../../types';
+import type { ArticlePreview, PageLink } from '../../types';
import { ROUTES } from '../constants';
-import { getImageFromRawData } from './images';
-
-/**
- * Convert raw data to a Link object.
- *
- * @param data - An object.
- * @param {number} data.databaseId - The data id.
- * @param {number} [data.logo] - The data logo.
- * @param {string} data.slug - The data slug.
- * @param {string} data.title - The data name.
- * @returns {PageLink} The link data (id, slug and title).
- */
-export const getPageLinkFromRawData = (
- data: RawThematicPreview | RawTopicPreview,
- kind: 'thematic' | 'topic'
-): PageLink => {
- const { databaseId, featuredImage, slug, title } = data;
- const baseUrl = `${
- kind === 'thematic' ? ROUTES.THEMATICS.INDEX : ROUTES.TOPICS
- }/`;
-
- return {
- id: databaseId,
- logo: featuredImage ? getImageFromRawData(featuredImage.node) : undefined,
- name: title,
- url: `${baseUrl}${slug}`,
- };
-};
/**
* Method to sort PageLink objects by name.
@@ -73,55 +37,28 @@ export const getLinksItemData = (links: PageLink[]): LinksWidgetItemData[] =>
/**
* Retrieve the posts list with the article URL.
*
- * @param {Article[]} posts - An array of articles.
+ * @param {ArticlePreview[]} posts - An array of articles.
* @returns {PostData[]} An array of posts with full article URL.
*/
-export const getPostsWithUrl = (posts: Article[]): PostData[] =>
- posts.map(({ intro, meta, slug, title, ...post }) => {
+export const getPostsWithUrl = (posts: ArticlePreview[]): PostData[] =>
+ posts.map(({ id, intro, meta, slug, title, ...post }) => {
return {
...post,
cover: meta.cover ? <NextImage {...meta.cover} /> : undefined,
excerpt: intro,
heading: title,
+ id,
meta: {
publicationDate: meta.dates.publication,
updateDate: meta.dates.update,
wordsCount: meta.wordsCount,
- author: meta.author?.name,
thematics: meta.thematics,
- topics: meta.topics,
- comments:
- meta.commentsCount === undefined
- ? undefined
- : {
- count: meta.commentsCount,
- postHeading: title,
- url: `${ROUTES.ARTICLE}/${slug}#comments`,
- },
+ comments: {
+ count: meta.commentsCount ?? 0,
+ postHeading: title,
+ url: `${ROUTES.ARTICLE}/${slug}#comments`,
+ },
},
url: `${ROUTES.ARTICLE}/${slug}`,
};
});
-
-/**
- * Retrieve the posts list from raw data.
- *
- * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
- * @returns {PostData[]} An array of posts.
- */
-export const getPostsList = async (
- rawData: EdgesResponse<RawArticle>[]
-): Promise<PostData[]> => {
- const articlesList: RawArticle[] = [];
- rawData.forEach((articleData) => {
- articleData.edges.forEach((edge) => {
- articlesList.push(edge.node);
- });
- });
-
- return getPostsWithUrl(
- await Promise.all(
- articlesList.map(async (article) => getArticleFromRawData(article))
- )
- );
-};
diff --git a/src/utils/helpers/rss.ts b/src/utils/helpers/rss.ts
index d9c3b1e..82fa1ee 100644
--- a/src/utils/helpers/rss.ts
+++ b/src/utils/helpers/rss.ts
@@ -1,28 +1,25 @@
import { Feed } from 'feed';
import {
- getArticleFromRawData,
- getArticles,
- getTotalArticles,
+ convertPostPreviewToArticlePreview,
+ fetchPostsList,
+ fetchPostsCount,
} from '../../services/graphql';
-import type { Article } from '../../types';
+import type { ArticlePreview } from '../../types';
import { CONFIG } from '../config';
import { ROUTES } from '../constants';
/**
* Retrieve the data for all the articles.
*
- * @returns {Promise<Article[]>} - All the articles.
+ * @returns {Promise<ArticlePreview[]>} - All the articles.
*/
-const getAllArticles = async (): Promise<Article[]> => {
- const totalArticles = await getTotalArticles();
- const rawArticles = await getArticles({ first: totalArticles });
- const articles: Article[] = [];
+const getAllArticles = async (): Promise<ArticlePreview[]> => {
+ const totalPosts = await fetchPostsCount();
+ const posts = await fetchPostsList({ first: totalPosts });
- rawArticles.edges.forEach(async (edge) => {
- articles.push(await getArticleFromRawData(edge.node));
- });
-
- return articles;
+ return posts.edges.map((edge) =>
+ convertPostPreviewToArticlePreview(edge.node)
+ );
};
/**
diff --git a/src/utils/helpers/server/projects.ts b/src/utils/helpers/server/projects.ts
index ed73da8..c1a3d10 100644
--- a/src/utils/helpers/server/projects.ts
+++ b/src/utils/helpers/server/projects.ts
@@ -1,10 +1,6 @@
import { readdirSync } from 'fs';
import path from 'path';
-import {
- type MDXProjectMeta,
- type ProjectCard,
- type ProjectPreview,
-} from '../../../types';
+import type { MDXProjectMeta, Project, ProjectPreview } from '../../../types';
/**
* Retrieve all the projects filename.
@@ -24,9 +20,7 @@ export const getProjectFilenames = (): string[] => {
* @param {string} filename - The project filename.
* @returns {Promise<ProjectPreview>}
*/
-export const getProjectData = async (
- filename: string
-): Promise<ProjectPreview> => {
+export const getProjectData = async (filename: string): Promise<Project> => {
try {
const {
meta,
@@ -53,7 +47,7 @@ export const getProjectData = async (
},
},
slug: filename,
- title: title,
+ title,
};
} catch (err) {
console.error(err);
@@ -65,28 +59,24 @@ export const getProjectData = async (
* Retrieve all the projects data using filenames.
*
* @param {string[]} filenames - The filenames without extension.
- * @returns {Promise<ProjectCard[]>} - An array of projects data.
+ * @returns {Promise<ProjectPreview[]>} - An array of projects data.
*/
export const getProjectsData = async (
filenames: string[]
-): Promise<ProjectCard[]> => {
- return Promise.all(
- filenames.map(async (filename) => {
- const { id, meta, slug, title } = await getProjectData(filename);
- const { cover, dates, tagline, technologies } = meta;
- return { id, meta: { cover, dates, tagline, technologies }, slug, title };
- })
- );
-};
+): Promise<ProjectPreview[]> =>
+ Promise.all(filenames.map(async (filename) => getProjectData(filename)));
/**
* Method to sort an array of projects by publication date.
*
- * @param {ProjectCard} a - A single project.
- * @param {ProjectCard} b - A single project.
+ * @param {ProjectPreview} a - A single project.
+ * @param {ProjectPreview} b - A single project.
* @returns The result used by Array.sort() method: 1 || -1 || 0.
*/
-const sortProjectsByPublicationDate = (a: ProjectCard, b: ProjectCard) => {
+const sortProjectsByPublicationDate = (
+ a: ProjectPreview,
+ b: ProjectPreview
+) => {
if (a.meta.dates.publication < b.meta.dates.publication) return 1;
if (a.meta.dates.publication > b.meta.dates.publication) return -1;
return 0;
@@ -95,9 +85,9 @@ const sortProjectsByPublicationDate = (a: ProjectCard, b: ProjectCard) => {
/**
* Retrieve all projects in content folder sorted by publication date.
*
- * @returns {Promise<ProjectCard[]>} An array of projects.
+ * @returns {Promise<ProjectPreview[]>} An array of projects.
*/
-export const getProjectsCard = async (): Promise<ProjectCard[]> => {
+export const getProjectsCard = async (): Promise<ProjectPreview[]> => {
const filenames = getProjectFilenames();
const projects = await getProjectsData(filenames);
diff --git a/src/utils/hooks/use-article.ts b/src/utils/hooks/use-article.ts
index f339f7f..5e54ee4 100644
--- a/src/utils/hooks/use-article.ts
+++ b/src/utils/hooks/use-article.ts
@@ -1,11 +1,7 @@
import { useEffect, useState } from 'react';
import useSWR from 'swr';
-import {
- articleBySlugQuery,
- fetchAPI,
- getArticleFromRawData,
-} from '../../services/graphql';
-import type { Article, Maybe, RawArticle } from '../../types';
+import { convertPostToArticle, fetchPost } from '../../services/graphql';
+import type { Article, Maybe } from '../../types';
export type UseArticleConfig = {
/**
@@ -28,24 +24,12 @@ export const useArticle = ({
slug,
fallback,
}: UseArticleConfig): Article | undefined => {
- const { data } = useSWR(
- slug ? { query: articleBySlugQuery, variables: { slug } } : null,
- fetchAPI<RawArticle, typeof articleBySlugQuery>,
- {}
- );
- const [article, setArticle] = useState<Maybe<Article>>();
+ const { data } = useSWR(slug, fetchPost, {});
+ const [article, setArticle] = useState<Maybe<Article>>(fallback);
useEffect(() => {
- const getArticle = async () => {
- if (data) {
- setArticle(await getArticleFromRawData(data.post));
- } else {
- setArticle(fallback);
- }
- };
-
- getArticle();
- }, [data, fallback]);
+ if (data) convertPostToArticle(data).then((post) => setArticle(post));
+ }, [data]);
return article;
};
diff --git a/src/utils/hooks/use-comments.ts b/src/utils/hooks/use-comments.ts
index ac723e9..94a2d7e 100644
--- a/src/utils/hooks/use-comments.ts
+++ b/src/utils/hooks/use-comments.ts
@@ -1,9 +1,13 @@
import useSWR from 'swr';
-import { getAllComments } from '../../services/graphql';
+import {
+ type FetchCommentsListInput,
+ fetchCommentsList,
+ convertWPCommentToComment,
+ buildCommentsTree,
+} from '../../services/graphql';
import type { SingleComment } from '../../types';
-export type UseCommentsConfig = {
- contentId?: string | number;
+export type UseCommentsConfig = FetchCommentsListInput & {
fallback?: SingleComment[];
};
@@ -14,10 +18,15 @@ export type UseCommentsConfig = {
* @returns {SingleComment[]|undefined}
*/
export const useComments = ({
- contentId,
fallback,
+ ...input
}: UseCommentsConfig): SingleComment[] | undefined => {
- const { data } = useSWR(contentId ? { contentId } : null, getAllComments, {});
+ const { data } = useSWR(input, fetchCommentsList, {});
- return data ?? fallback;
+ if (!data) return fallback;
+
+ const comments = data.map(convertWPCommentToComment);
+ const commentsTree = buildCommentsTree(comments);
+
+ return commentsTree;
};
diff --git a/src/utils/hooks/use-pagination/use-pagination.test.ts b/src/utils/hooks/use-pagination/use-pagination.test.ts
index 20cb37e..18f3ac5 100644
--- a/src/utils/hooks/use-pagination/use-pagination.test.ts
+++ b/src/utils/hooks/use-pagination/use-pagination.test.ts
@@ -1,8 +1,11 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getConnection } from '../../../../tests/utils/graphql';
-import type { EdgesResponse, GraphQLEdgesInput, Search } from '../../../types';
-import { usePagination } from './use-pagination';
+import type { GraphQLConnection } from '../../../types';
+import {
+ type UsePaginationFetcherInput,
+ usePagination,
+} from './use-pagination';
type Data = {
id: number;
@@ -24,7 +27,7 @@ describe('usePagination', () => {
after,
first,
search,
- }: GraphQLEdgesInput & Search): Promise<EdgesResponse<Data>> => {
+ }: UsePaginationFetcherInput): Promise<GraphQLConnection<Data>> => {
const filteredData = search
? data.filter((d) => d.title.includes(search))
: data;
diff --git a/src/utils/hooks/use-pagination/use-pagination.ts b/src/utils/hooks/use-pagination/use-pagination.ts
index 4df521b..2a40aa4 100644
--- a/src/utils/hooks/use-pagination/use-pagination.ts
+++ b/src/utils/hooks/use-pagination/use-pagination.ts
@@ -1,22 +1,25 @@
import { useCallback } from 'react';
import useSWRInfinite, { type SWRInfiniteKeyLoader } from 'swr/infinite';
import type {
- EdgesResponse,
+ GraphQLConnection,
GraphQLEdgesInput,
Maybe,
Nullable,
- Search,
} from '../../../types';
+export type UsePaginationFetcherInput = GraphQLEdgesInput & {
+ search?: string;
+};
+
export type UsePaginationConfig<T> = {
/**
* The initial data.
*/
- fallback?: EdgesResponse<T>[];
+ fallback?: GraphQLConnection<T>[];
/**
* A function to fetch more data.
*/
- fetcher: (props: GraphQLEdgesInput & Search) => Promise<EdgesResponse<T>>;
+ fetcher: (props: UsePaginationFetcherInput) => Promise<GraphQLConnection<T>>;
/**
* The number of results per page.
*/
@@ -31,7 +34,7 @@ export type UsePaginationReturn<T> = {
/**
* The data from the API.
*/
- data: Maybe<EdgesResponse<T>[]>;
+ data: Maybe<GraphQLConnection<T>[]>;
/**
* An error thrown by fetcher.
*/
@@ -88,8 +91,8 @@ export const usePagination = <T>({
perPage,
searchQuery,
}: UsePaginationConfig<T>): UsePaginationReturn<T> => {
- const getKey: SWRInfiniteKeyLoader<EdgesResponse<T>> = useCallback(
- (pageIndex, previousPageData): Nullable<GraphQLEdgesInput & Search> => {
+ const getKey: SWRInfiniteKeyLoader<GraphQLConnection<T>> = useCallback(
+ (pageIndex, previousPageData): Nullable<UsePaginationFetcherInput> => {
if (previousPageData && !previousPageData.edges.length) return null;
return {
diff --git a/src/utils/hooks/use-posts-list/use-posts-list.test.ts b/src/utils/hooks/use-posts-list/use-posts-list.test.ts
index 1d11111..ff69de2 100644
--- a/src/utils/hooks/use-posts-list/use-posts-list.test.ts
+++ b/src/utils/hooks/use-posts-list/use-posts-list.test.ts
@@ -1,13 +1,13 @@
import { describe, expect, it } from '@jest/globals';
import { act, renderHook } from '@testing-library/react';
-import { getArticles } from '../../../services/graphql';
+import { fetchPostsList } from '../../../services/graphql';
import { usePostsList } from './use-posts-list';
describe('usePostsList', () => {
it('can return the first new result index when loading more posts', async () => {
const perPage = 5;
const { result } = renderHook(() =>
- usePostsList({ fetcher: getArticles, perPage })
+ usePostsList({ fetcher: fetchPostsList, perPage })
);
expect.assertions(2);
diff --git a/src/utils/hooks/use-posts-list/use-posts-list.ts b/src/utils/hooks/use-posts-list/use-posts-list.ts
index 980d531..bb77f31 100644
--- a/src/utils/hooks/use-posts-list/use-posts-list.ts
+++ b/src/utils/hooks/use-posts-list/use-posts-list.ts
@@ -1,29 +1,34 @@
-import { useCallback, useEffect, useState } from 'react';
-import type { PostData } from '../../../components';
-import type { Maybe, RawArticle } from '../../../types';
-import { getPostsList } from '../../helpers';
+import { useCallback, useState } from 'react';
+import type {
+ ArticlePreview,
+ GraphQLConnection,
+ GraphQLEdge,
+ Maybe,
+ WPPostPreview,
+} from '../../../types';
import {
type UsePaginationConfig,
usePagination,
type UsePaginationReturn,
} from '../use-pagination';
+import { convertPostPreviewToArticlePreview } from 'src/services/graphql';
export type usePostsListReturn = Omit<
- UsePaginationReturn<RawArticle>,
+ UsePaginationReturn<WPPostPreview>,
'data'
> & {
/**
- * The index of the first new result when loading more posts.
+ * The articles list.
*/
- firstNewResultIndex: Maybe<number>;
+ articles: Maybe<GraphQLConnection<ArticlePreview>[]>;
/**
- * The posts list.
+ * The index of the first new result when loading more posts.
*/
- posts: Maybe<PostData[]>;
+ firstNewResultIndex: Maybe<number>;
};
export const usePostsList = (
- config: UsePaginationConfig<RawArticle>
+ config: UsePaginationConfig<WPPostPreview>
): usePostsListReturn => {
const {
data,
@@ -40,15 +45,6 @@ export const usePostsList = (
} = usePagination(config);
const [firstNewResultIndex, setFirstNewResultIndex] =
useState<Maybe<number>>(undefined);
- const [posts, setPosts] = useState<Maybe<PostData[]>>(undefined);
-
- useEffect(() => {
- const getPosts = async () => {
- if (data) setPosts(await getPostsList(data));
- };
-
- getPosts();
- }, [data]);
const handleLoadMore = useCallback(async () => {
setFirstNewResultIndex(size * config.perPage + 1);
@@ -56,7 +52,22 @@ export const usePostsList = (
await loadMore();
}, [config.perPage, loadMore, size]);
+ const articles: Maybe<GraphQLConnection<ArticlePreview>[]> = data?.map(
+ (page): GraphQLConnection<ArticlePreview> => {
+ return {
+ edges: page.edges.map((edge): GraphQLEdge<ArticlePreview> => {
+ return {
+ cursor: edge.cursor,
+ node: convertPostPreviewToArticlePreview(edge.node),
+ };
+ }),
+ pageInfo: page.pageInfo,
+ };
+ }
+ );
+
return {
+ articles,
error,
firstNewResultIndex,
hasNextPage,
@@ -67,7 +78,6 @@ export const usePostsList = (
isRefreshing,
isValidating,
loadMore: handleLoadMore,
- posts,
size,
};
};
diff --git a/tests/utils/graphql/connections.ts b/tests/utils/graphql/connections.ts
index f38fa59..dc4bfc6 100644
--- a/tests/utils/graphql/connections.ts
+++ b/tests/utils/graphql/connections.ts
@@ -1,4 +1,9 @@
-import type { EdgesResponse, GraphQLEdges, Maybe } from '../../../src/types';
+import type {
+ GraphQLConnection,
+ GraphQLEdge,
+ Maybe,
+ Nullable,
+} from '../../../src/types';
import { CONFIG } from '../../../src/utils/config';
/**
@@ -8,7 +13,7 @@ import { CONFIG } from '../../../src/utils/config';
* @param {number} offset - The offset.
* @returns {Array<Edge<T>>} The edges.
*/
-export const getEdges = <T>(data: T[], offset: number): GraphQLEdges<T>[] =>
+export const getEdges = <T>(data: T[], offset: number): GraphQLEdge<T>[] =>
data.map((singleData, index) => {
const currentItemNumber = index + 1;
@@ -21,7 +26,7 @@ export const getEdges = <T>(data: T[], offset: number): GraphQLEdges<T>[] =>
type GetConnectionProps<T> = {
data: Maybe<T[]>;
first: Maybe<number>;
- after: Maybe<string>;
+ after: Maybe<Nullable<string>>;
};
/**
@@ -37,7 +42,7 @@ export const getConnection = <T>({
after,
data = [],
first = CONFIG.postsPerPage,
-}: GetConnectionProps<T>): EdgesResponse<T> => {
+}: GetConnectionProps<T>): GraphQLConnection<T> => {
const afterInt = after ? Number(after.replace('cursor', '')) : 0;
const edges = getEdges(data.slice(afterInt, afterInt + first), afterInt);
const endCursor =
@@ -47,7 +52,9 @@ export const getConnection = <T>({
edges,
pageInfo: {
endCursor,
+ hasPreviousPage: typeof after !== 'undefined',
hasNextPage: data.length - afterInt > first,
+ startCursor: after ?? 'cursor1',
total: data.length,
},
};