diff options
Diffstat (limited to 'src/services/graphql')
| -rw-r--r-- | src/services/graphql/api.ts | 319 | ||||
| -rw-r--r-- | src/services/graphql/articles.query.ts | 191 | ||||
| -rw-r--r-- | src/services/graphql/articles.ts | 200 | ||||
| -rw-r--r-- | src/services/graphql/comments.mutation.ts | 30 | ||||
| -rw-r--r-- | src/services/graphql/comments.query.ts | 21 | ||||
| -rw-r--r-- | src/services/graphql/comments.ts | 102 | ||||
| -rw-r--r-- | src/services/graphql/contact.mutation.ts | 25 | ||||
| -rw-r--r-- | src/services/graphql/contact.ts | 26 | ||||
| -rw-r--r-- | src/services/graphql/mutations.ts | 82 | ||||
| -rw-r--r-- | src/services/graphql/queries.ts | 535 | ||||
| -rw-r--r-- | src/services/graphql/thematics.query.ts | 125 | ||||
| -rw-r--r-- | src/services/graphql/thematics.ts | 162 | ||||
| -rw-r--r-- | src/services/graphql/topics.query.ts | 137 | ||||
| -rw-r--r-- | src/services/graphql/topics.ts | 164 |
14 files changed, 1486 insertions, 633 deletions
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts index a5be026..009aea4 100644 --- a/src/services/graphql/api.ts +++ b/src/services/graphql/api.ts @@ -1,25 +1,312 @@ -import { RequestType, VariablesType } from '@ts/types/app'; import { settings } from '@utils/config'; -import { GraphQLClient } from 'graphql-request'; +import { + articleBySlugQuery, + articlesCardQuery, + articlesEndCursor, + articlesQuery, + articlesSlugQuery, + totalArticlesQuery, +} from './articles.query'; +import { sendCommentMutation } from './comments.mutation'; +import { commentsQuery } from './comments.query'; +import { sendMailMutation } from './contact.mutation'; +import { + thematicBySlugQuery, + thematicsListQuery, + thematicsSlugQuery, + totalThematicsQuery, +} from './thematics.query'; +import { + topicBySlugQuery, + topicsListQuery, + topicsSlugQuery, + totalTopicsQuery, +} from './topics.query'; -export const getGraphQLClient = (): GraphQLClient => { - const apiUrl = settings.api.url; +export type Mutations = typeof sendMailMutation | typeof sendCommentMutation; - if (!apiUrl) throw new Error('API URL not defined.'); +export type Queries = + | typeof articlesQuery + | typeof articleBySlugQuery + | typeof articlesCardQuery + | typeof articlesEndCursor + | typeof articlesSlugQuery + | typeof commentsQuery + | typeof thematicBySlugQuery + | typeof thematicsListQuery + | typeof thematicsSlugQuery + | typeof topicBySlugQuery + | typeof topicsListQuery + | typeof topicsSlugQuery + | typeof totalArticlesQuery + | typeof totalThematicsQuery + | typeof totalTopicsQuery; - return new GraphQLClient(apiUrl); +export type ArticleResponse<T> = { + post: T; }; -export const fetchApi = async <T extends RequestType>( - query: string, - variables: VariablesType<T> -): Promise<T> => { - const client = getGraphQLClient(); +export type ArticlesResponse<T> = { + posts: T; +}; + +export type CommentsResponse<T> = { + comments: T; +}; + +export type SendCommentResponse<T> = { + createComment: T; +}; + +export type SendMailResponse<T> = { + sendEmail: T; +}; - try { - return await client.request(query, variables); - } catch (error) { - console.error(error, undefined, 2); - process.exit(1); +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 PageInfo = { + endCursor: string; + hasNextPage: boolean; + total: number; +}; + +export type Edges<T> = { + cursor: string; + node: T; +}; + +export type EdgesResponse<T> = { + edges: Edges<T>[]; + pageInfo: PageInfo; +}; + +export type NodeResponse<T> = { + node: T; +}; + +export type NodesResponse<T> = { + nodes: T[]; +}; + +export type EndCursor = Pick< + EdgesResponse<Pick<PageInfo, 'endCursor'>>, + 'pageInfo' +>; + +export type ResponseMap<T> = { + [articleBySlugQuery]: ArticleResponse<T>; + [articlesCardQuery]: ArticlesResponse<NodesResponse<T>>; + [articlesEndCursor]: ArticlesResponse<EndCursor>; + [articlesQuery]: ArticlesResponse<EdgesResponse<T>>; + [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>; + [commentsQuery]: CommentsResponse<NodesResponse<T>>; + [sendCommentMutation]: SendCommentResponse<T>; + [sendMailMutation]: SendMailResponse<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>; +}; + +export type GraphQLResponse< + T extends keyof ResponseMap<U>, + U +> = ResponseMap<U>[T]; + +export type BySlugVar = { + /** + * A slug. + */ + slug: string; +}; + +export type EdgesVars = { + /** + * A cursor. + */ + after?: string; + /** + * The number of items to return. + */ + first: number; + /** + * A search query. + */ + search?: string; +}; + +export type ByContentIdVar = { + /** + * An article id. + */ + contentId: number; +}; + +export type SearchVar = { + /** + * A search term. + */ + search?: string; +}; + +export type SendCommentVars = { + /** + * The author name. + */ + author: string; + /** + * The author e-mail address. + */ + authorEmail: string; + /** + * The author website. + */ + authorUrl: string; + /** + * A mutation id. + */ + clientMutationId: string; + /** + * A post or page id. + */ + commentOn: number; + /** + * The comment body. + */ + content: string; + /** + * The comment parent. + */ + parent?: number; +}; + +export type SendMailVars = { + /** + * The mail body. + */ + body: string; + /** + * A mutation id. + */ + clientMutationId: string; + /** + * The reply to e-mail address. + */ + replyTo: string; + /** + * The mail subject. + */ + subject: string; +}; + +export type VariablesMap = { + [articleBySlugQuery]: BySlugVar; + [articlesCardQuery]: EdgesVars; + [articlesEndCursor]: EdgesVars; + [articlesQuery]: EdgesVars; + [articlesSlugQuery]: EdgesVars; + [commentsQuery]: ByContentIdVar; + [sendCommentMutation]: SendCommentVars; + [sendMailMutation]: SendMailVars; + [thematicBySlugQuery]: BySlugVar; + [thematicsListQuery]: EdgesVars; + [thematicsSlugQuery]: EdgesVars; + [topicBySlugQuery]: BySlugVar; + [topicsListQuery]: EdgesVars; + [topicsSlugQuery]: EdgesVars; + [totalArticlesQuery]: SearchVar; + [totalThematicsQuery]: null; + [totalTopicsQuery]: null; +}; + +export type FetchAPIProps<T extends Queries | Mutations> = { + /** + * A GraphQL API URL. + */ + api: string; + /** + * A GraphQL query. + */ + query: T; + /** + * (Optional) The query variables. + */ + variables?: VariablesMap[T]; +}; + +/** + * Fetch a GraphQL API. + * @param {object} obj - An object. + * @param {string} obj.api - A GraphQL API URL. + * @param {Queries} obj.query - A GraphQL query. + * @param {object} [obj.variables] - The query variables. + */ +export async function fetchAPI<T, U extends Queries | Mutations>({ + api, + query, + variables, +}: FetchAPIProps<U>): Promise<GraphQLResponse<U, T>> { + const response = await fetch(api, { + method: 'POST', + headers: { + 'content-type': 'application/json;charset=UTF-8', + }, + body: JSON.stringify({ + query, + variables, + }), + }); + + type JSONResponse = { + data?: GraphQLResponse<U, T>; + errors?: Array<{ message: string }>; + }; + + const { data, errors }: JSONResponse = await response.json(); + + if (response.ok) { + if (!data) return Promise.reject(new Error(`No data found"`)); + + return data; + } else { + console.error('Failed to fetch API'); + const error = new Error( + errors?.map((e) => e.message).join('\n') ?? 'unknown' + ); + return Promise.reject(error); + } +} + +/** + * Retrieve the API url from settings. + * + * @returns {string} The API url. + */ +export const getAPIUrl = (): string => { + const { url } = settings.api; + + if (!url) { + throw new Error('API url is not defined.'); } + + return url; }; diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts new file mode 100644 index 0000000..3e1f575 --- /dev/null +++ b/src/services/graphql/articles.query.ts @@ -0,0 +1,191 @@ +/** + * 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 articlesEndCursor = `query EndCursorAfter($first: Int) { + posts(first: $first) { + pageInfo { + hasNextPage + endCursor + } + } +}`; diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts new file mode 100644 index 0000000..27406ac --- /dev/null +++ b/src/services/graphql/articles.ts @@ -0,0 +1,200 @@ +import { Slug, type Article, type ArticleCard } from '@ts/types/app'; +import { + type RawArticle, + type RawArticlePreview, + type TotalItems, +} from '@ts/types/raw-data'; +import { getAuthorFromRawData } from '@utils/helpers/author'; +import { getImageFromRawData } from '@utils/helpers/images'; +import { getPageLinkFromRawData } from '@utils/helpers/pages'; +import { + EdgesResponse, + EdgesVars, + EndCursor, + fetchAPI, + getAPIUrl, + PageInfo, +} from './api'; +import { + articleBySlugQuery, + articlesCardQuery, + articlesEndCursor, + 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>({ + api: getAPIUrl(), + query: totalArticlesQuery, + variables: { search }, + }); + + return response.posts.pageInfo.total; +}; + +export type GetArticlesReturn = { + articles: Article[]; + pageInfo: PageInfo; +}; + +/** + * Convert raw data to an Article object. + * + * @param {RawArticle} data - The page raw data. + * @returns {Article} The page data. + */ +export const getArticleFromRawData = (data: RawArticle): Article => { + const { + acfPosts, + author, + commentCount, + contentParts, + databaseId, + date, + featuredImage, + info, + modified, + slug, + title, + seo, + } = data; + + return { + content: 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 {EdgesVars} props - An object of GraphQL variables. + * @returns {Promise<EdgesResponse<RawArticle>>} The articles data. + */ +export const getArticles = async ( + props: EdgesVars +): Promise<EdgesResponse<RawArticle>> => { + const response = await fetchAPI<RawArticle, typeof articlesQuery>({ + api: getAPIUrl(), + 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 {EdgesVars} obj - An object. + * @param {number} obj.first - The number of articles. + * @returns {Promise<ArticleCard[]>} - The article cards data. + */ +export const getArticlesCard = async ({ + first, +}: EdgesVars): Promise<ArticleCard[]> => { + const response = await fetchAPI<RawArticlePreview, typeof articlesCardQuery>({ + api: getAPIUrl(), + 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>({ + api: getAPIUrl(), + 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>({ + api: getAPIUrl(), + query: articlesSlugQuery, + variables: { first: totalArticles }, + }); + + return response.posts.edges.map((edge) => edge.node.slug); +}; + +/** + * Retrieve the last cursor. + * + * @param {EdgesVars} props - An object of GraphQL variables. + * @returns {Promise<string>} - The end cursor. + */ +export const getArticlesEndCursor = async ( + props: EdgesVars +): Promise<string> => { + const response = await fetchAPI<EndCursor, typeof articlesEndCursor>({ + api: getAPIUrl(), + query: articlesEndCursor, + variables: { ...props }, + }); + + return response.posts.pageInfo.endCursor; +}; diff --git a/src/services/graphql/comments.mutation.ts b/src/services/graphql/comments.mutation.ts new file mode 100644 index 0000000..f52c7e9 --- /dev/null +++ b/src/services/graphql/comments.mutation.ts @@ -0,0 +1,30 @@ +/** + * 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 new file mode 100644 index 0000000..ef93e89 --- /dev/null +++ b/src/services/graphql/comments.query.ts @@ -0,0 +1,21 @@ +/** + * Query the comments data by post id. + */ +export const commentsQuery = `query CommentsByPostId($contentId: ID!) { + comments(where: {contentId: $contentId, order: ASC, orderby: COMMENT_DATE}) { + nodes { + approved + author { + node { + gravatarUrl + name + url + } + } + content + databaseId + date + parentDatabaseId + } + } +}`; diff --git a/src/services/graphql/comments.ts b/src/services/graphql/comments.ts new file mode 100644 index 0000000..28ddfd0 --- /dev/null +++ b/src/services/graphql/comments.ts @@ -0,0 +1,102 @@ +import { Comment } from '@ts/types/app'; +import { RawComment } from '@ts/types/raw-data'; +import { getAuthorFromRawData } from '@utils/helpers/author'; +import { fetchAPI, getAPIUrl, SendCommentVars } from './api'; +import { sendCommentMutation } from './comments.mutation'; +import { commentsQuery } from './comments.query'; + +/** + * Create a comments tree with replies. + * + * @param {Comment[]} comments - A flatten comments list. + * @returns {Comment[]} An array of comments with replies. + */ +export const buildCommentsTree = (comments: Comment[]): Comment[] => { + type CommentsHashTable = { + [key: string]: Comment; + }; + + const hashTable: CommentsHashTable = Object.create(null); + const commentsTree: Comment[] = []; + + 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; +}; + +/** + * Convert a comment from RawComment to Comment type. + * + * @param {RawComment} comment - A raw comment. + * @returns {Comment} A formatted comment. + */ +export const getCommentFromRawData = (comment: RawComment): Comment => { + const { author, databaseId, date, parentDatabaseId, ...data } = comment; + + return { + id: databaseId, + meta: { + author: getAuthorFromRawData(author.node, 'comment'), + date, + }, + parentId: parentDatabaseId, + replies: [], + ...data, + }; +}; + +/** + * Retrieve a comments list by post id. + * + * @param {number} id - A post id. + * @returns {Promise<Comment[]>} The comments list. + */ +export const getPostComments = async (id: number): Promise<Comment[]> => { + const response = await fetchAPI<RawComment, typeof commentsQuery>({ + api: getAPIUrl(), + query: commentsQuery, + variables: { contentId: id }, + }); + + const comments = response.comments.nodes.map((comment) => + getCommentFromRawData(comment) + ); + + return buildCommentsTree(comments); +}; + +export type SentComment = { + clientMutationId: string; + success: boolean; + comment: { + approved: boolean; + } | null; +}; + +/** + * Send a comment using GraphQL API. + * + * @param {SendCommentVars} data - The comment data. + * @returns {Promise<SentEmail>} The mutation response. + */ +export const sendComment = async ( + data: SendCommentVars +): Promise<SentComment> => { + const response = await fetchAPI<SentComment, typeof sendCommentMutation>({ + api: getAPIUrl(), + query: sendCommentMutation, + variables: { ...data }, + }); + + return response.createComment; +}; diff --git a/src/services/graphql/contact.mutation.ts b/src/services/graphql/contact.mutation.ts new file mode 100644 index 0000000..b82fc07 --- /dev/null +++ b/src/services/graphql/contact.mutation.ts @@ -0,0 +1,25 @@ +/** + * 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 new file mode 100644 index 0000000..00c6ca2 --- /dev/null +++ b/src/services/graphql/contact.ts @@ -0,0 +1,26 @@ +import { fetchAPI, getAPIUrl, SendMailVars } 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 {sendMailVars} data - The mail data. + * @returns {Promise<SentEmail>} The mutation response. + */ +export const sendMail = async (data: SendMailVars): Promise<SentEmail> => { + const response = await fetchAPI<SentEmail, typeof sendMailMutation>({ + api: getAPIUrl(), + query: sendMailMutation, + variables: { ...data }, + }); + + return response.sendEmail; +}; diff --git a/src/services/graphql/mutations.ts b/src/services/graphql/mutations.ts deleted file mode 100644 index c697835..0000000 --- a/src/services/graphql/mutations.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { CommentData, CreateComment, CreatedComment } from '@ts/types/comments'; -import { ContactData, SendEmail } from '@ts/types/contact'; -import { gql } from 'graphql-request'; -import { fetchApi } from './api'; - -//============================================================================== -// Comment mutation -//============================================================================== - -export const createComment = async ( - data: CommentData -): Promise<CreatedComment> => { - const mutation = gql` - mutation CreateComment( - $author: String! - $authorEmail: String! - $authorUrl: String! - $content: String! - $parent: ID! - $commentOn: Int! - $mutationId: String! - ) { - createComment( - input: { - author: $author - authorEmail: $authorEmail - authorUrl: $authorUrl - content: $content - parent: $parent - commentOn: $commentOn - clientMutationId: $mutationId - } - ) { - clientMutationId - success - comment { - approved - } - } - } - `; - - const variables = { ...data }; - const response = await fetchApi<CreateComment>(mutation, variables); - - return response.createComment; -}; - -//============================================================================== -// Contact mutation -//============================================================================== - -export const sendMail = async (data: ContactData) => { - const mutation = gql` - mutation SendEmail( - $subject: String! - $body: String! - $replyTo: String! - $mutationId: String! - ) { - sendEmail( - input: { - clientMutationId: $mutationId - body: $body - replyTo: $replyTo - subject: $subject - } - ) { - clientMutationId - message - sent - origin - replyTo - to - } - } - `; - - const variables = { ...data }; - const response = await fetchApi<SendEmail>(mutation, variables); - return response.sendEmail; -}; diff --git a/src/services/graphql/queries.ts b/src/services/graphql/queries.ts deleted file mode 100644 index 9caf62b..0000000 --- a/src/services/graphql/queries.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { Slug } from '@ts/types/app'; -import { Article, PostBy, TotalArticles } from '@ts/types/articles'; -import { - AllPostsSlug, - LastPostCursor, - PostsList, - RawPostsList, -} from '@ts/types/blog'; -import { Comment, CommentsByPostId } from '@ts/types/comments'; -import { - AllTopics, - AllTopicsSlug, - AllThematics, - AllThematicsSlug, - Topic, - TopicBy, - TopicPreview, - Thematic, - ThematicBy, - ThematicPreview, -} from '@ts/types/taxonomies'; -import { - getFormattedPost, - getFormattedPostPreview, - getFormattedTopic, - getFormattedThematic, - getFormattedComments, - buildCommentsTree, -} from '@utils/helpers/format'; -import { gql } from 'graphql-request'; -import { fetchApi } from './api'; - -//============================================================================== -// Posts list queries -//============================================================================== - -export const getPostsTotal = async (): Promise<number> => { - const query = gql` - query PostsTotal { - posts { - pageInfo { - total - } - } - } - `; - - const response = await fetchApi<TotalArticles>(query, null); - return response.posts.pageInfo.total; -}; - -export const getPublishedPosts = async ({ - first = 10, - after = '', - searchQuery = '', -}: { - first: number; - after?: string; - searchQuery?: string; -}): Promise<PostsList> => { - const query = gql` - query AllPublishedPosts($first: Int, $after: String, $searchQuery: String) { - posts( - after: $after - first: $first - where: { - status: PUBLISH - orderby: { field: DATE, order: DESC } - search: $searchQuery - } - ) { - edges { - cursor - node { - acfPosts { - postsInTopic { - ... on Topic { - databaseId - featuredImage { - node { - altText - sourceUrl - title - } - } - id - slug - title - } - } - postsInThematic { - ... on Thematic { - databaseId - id - slug - title - } - } - } - commentCount - contentParts { - beforeMore - } - date - featuredImage { - node { - altText - sourceUrl - title - } - } - id - info { - readingTime - wordsCount - } - databaseId - modified - slug - title - } - } - pageInfo { - endCursor - hasNextPage - total - } - } - } - `; - - const variables = { first, after, searchQuery }; - const response = await fetchApi<RawPostsList>(query, variables); - const formattedPosts = response.posts.edges.map((post) => { - return getFormattedPostPreview(post.node); - }); - - return { - posts: formattedPosts, - pageInfo: response.posts.pageInfo, - }; -}; - -export const getAllPostsSlug = async (): Promise<Slug[]> => { - // 10 000 is an arbitrary number that I use for small websites. - const query = gql` - query AllPostsSlug { - posts(first: 10000) { - nodes { - slug - } - } - } - `; - - const response = await fetchApi<AllPostsSlug>(query, null); - return response.posts.nodes; -}; - -//============================================================================== -// Single Post query -//============================================================================== - -export const getPostBySlug = async (slug: string): Promise<Article> => { - const query = gql` - query PostBySlug($slug: ID!) { - post(id: $slug, idType: SLUG) { - acfPosts { - postsInTopic { - ... on Topic { - id - featuredImage { - node { - altText - sourceUrl - title - } - } - slug - title - } - } - postsInThematic { - ... on Thematic { - id - slug - title - } - } - } - author { - node { - firstName - lastName - name - } - } - commentCount - contentParts { - afterMore - beforeMore - } - databaseId - date - featuredImage { - node { - altText - sourceUrl - title - } - } - id - info { - readingTime - wordsCount - } - modified - seo { - metaDesc - title - } - title - } - } - `; - const variables = { slug }; - const response = await fetchApi<PostBy>(query, variables); - - return getFormattedPost(response.post); -}; - -//============================================================================== -// Comments query -//============================================================================== - -export const getCommentsByPostId = async (id: number): Promise<Comment[]> => { - const query = gql` - query PostComments($id: ID!) { - comments(where: { contentId: $id, order: ASC, orderby: COMMENT_DATE }) { - nodes { - approved - author { - node { - databaseId - gravatarUrl - name - url - } - } - content - databaseId - date - parentDatabaseId - } - } - } - `; - - const variables = { id }; - const response = await fetchApi<CommentsByPostId>(query, variables); - const formattedComments = getFormattedComments(response.comments.nodes); - - return buildCommentsTree(formattedComments); -}; - -//============================================================================== -// Topic query -//============================================================================== - -export const getTopicBySlug = async (slug: string): Promise<Topic> => { - const query = gql` - query TopicBySlug($slug: ID!) { - topic(id: $slug, idType: SLUG) { - acfTopics { - officialWebsite - postsInTopic { - ... on Post { - acfPosts { - postsInTopic { - ... on Topic { - databaseId - featuredImage { - node { - altText - sourceUrl - title - } - } - id - slug - title - } - } - postsInThematic { - ... on Thematic { - databaseId - id - slug - title - } - } - } - id - info { - readingTime - wordsCount - } - commentCount - contentParts { - beforeMore - } - databaseId - date - featuredImage { - node { - altText - sourceUrl - title - } - } - modified - slug - title - } - } - } - contentParts { - afterMore - beforeMore - } - databaseId - date - featuredImage { - node { - altText - sourceUrl - title - } - } - id - info { - readingTime - wordsCount - } - modified - seo { - metaDesc - title - } - title - } - } - `; - const variables = { slug }; - const response = await fetchApi<TopicBy>(query, variables); - - return getFormattedTopic(response.topic); -}; - -export const getAllTopicsSlug = async (): Promise<Slug[]> => { - // 10 000 is an arbitrary number that I use for small websites. - const query = gql` - query AllTopicsSlug { - topics(first: 10000) { - nodes { - slug - } - } - } - `; - const response = await fetchApi<AllTopicsSlug>(query, null); - return response.topics.nodes; -}; - -export const getAllTopics = async (): Promise<TopicPreview[]> => { - // 10 000 is an arbitrary number that I use for small websites. - const query = gql` - query AllTopics { - topics(first: 10000, where: { orderby: { field: TITLE, order: ASC } }) { - nodes { - databaseId - slug - title - } - } - } - `; - - const response = await fetchApi<AllTopics>(query, null); - return response.topics.nodes; -}; - -//============================================================================== -// Thematic query -//============================================================================== - -export const getThematicBySlug = async (slug: string): Promise<Thematic> => { - const query = gql` - query ThematicBySlug($slug: ID!) { - thematic(id: $slug, idType: SLUG) { - acfThematics { - postsInThematic { - ... on Post { - acfPosts { - postsInTopic { - ... on Topic { - databaseId - featuredImage { - node { - altText - sourceUrl - title - } - } - id - slug - title - } - } - postsInThematic { - ... on Thematic { - databaseId - id - slug - title - } - } - } - id - info { - readingTime - wordsCount - } - commentCount - contentParts { - beforeMore - } - databaseId - date - featuredImage { - node { - altText - sourceUrl - title - } - } - modified - slug - title - } - } - } - contentParts { - afterMore - beforeMore - } - databaseId - date - id - info { - readingTime - wordsCount - } - modified - seo { - metaDesc - title - } - title - } - } - `; - const variables = { slug }; - const response = await fetchApi<ThematicBy>(query, variables); - - return getFormattedThematic(response.thematic); -}; - -export const getAllThematicsSlug = async (): Promise<Slug[]> => { - // 10 000 is an arbitrary number that I use for small websites. - const query = gql` - query AllThematicsSlug { - thematics(first: 10000) { - nodes { - slug - } - } - } - `; - const response = await fetchApi<AllThematicsSlug>(query, null); - return response.thematics.nodes; -}; - -export const getAllThematics = async (): Promise<ThematicPreview[]> => { - // 10 000 is an arbitrary number that I use for small websites. - const query = gql` - query AllThematics { - thematics( - first: 10000 - where: { orderby: { field: TITLE, order: ASC } } - ) { - nodes { - databaseId - slug - title - } - } - } - `; - - const response = await fetchApi<AllThematics>(query, null); - return response.thematics.nodes; -}; - -export const getEndCursor = async ({ - first = 10, -}: { - first: number; -}): Promise<string> => { - const query = gql` - query EndCursorAfter($first: Int) { - posts(first: $first) { - pageInfo { - hasNextPage - endCursor - } - } - } - `; - - const variables = { first }; - const response = await fetchApi<LastPostCursor>(query, variables); - - return response.posts.pageInfo.endCursor; -}; diff --git a/src/services/graphql/thematics.query.ts b/src/services/graphql/thematics.query.ts new file mode 100644 index 0000000..5a82133 --- /dev/null +++ b/src/services/graphql/thematics.query.ts @@ -0,0 +1,125 @@ +/** + * 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 new file mode 100644 index 0000000..4dc69e7 --- /dev/null +++ b/src/services/graphql/thematics.ts @@ -0,0 +1,162 @@ +import { PageLink, Slug, Thematic } from '@ts/types/app'; +import { + RawArticle, + RawThematic, + RawThematicPreview, + TotalItems, +} from '@ts/types/raw-data'; +import { getImageFromRawData } from '@utils/helpers/images'; +import { getPageLinkFromRawData } from '@utils/helpers/pages'; +import { EdgesResponse, EdgesVars, fetchAPI, getAPIUrl } 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>({ + api: getAPIUrl(), + query: totalThematicsQuery, + }); + + return response.thematics.pageInfo.total; +}; + +/** + * Retrieve the given number of thematics from API. + * + * @param {EdgesVars} props - An object of GraphQL variables. + * @returns {Promise<EdgesResponse<RawThematicPreview>>} The thematics data. + */ +export const getThematicsPreview = async ( + props: EdgesVars +): Promise<EdgesResponse<RawThematicPreview>> => { + const response = await fetchAPI< + RawThematicPreview, + typeof thematicsListQuery + >({ api: getAPIUrl(), 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 = (data: RawThematic): 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) { + post.acfPosts.postsInTopic.forEach((topic) => + topics.push(getPageLinkFromRawData(topic, 'topic')) + ); + } + }); + + const topicsIds = topics.map((topic) => topic.id); + const uniqueTopics = topics.filter( + ({ id }, index) => !topicsIds.includes(id, index + 1) + ); + const sortTopicByName = (a: PageLink, b: PageLink) => { + var nameA = a.name.toUpperCase(); // ignore upper and lowercase + var nameB = b.name.toUpperCase(); // ignore upper and lowercase + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + + // names must be equal + return 0; + }; + + return uniqueTopics.sort(sortTopicByName); + }; + + return { + content: contentParts.afterMore, + id: databaseId, + intro: contentParts.beforeMore, + meta: { + articles: acfThematics.postsInThematic.map((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>({ + api: getAPIUrl(), + 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>({ + api: getAPIUrl(), + 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 new file mode 100644 index 0000000..57b2569 --- /dev/null +++ b/src/services/graphql/topics.query.ts @@ -0,0 +1,137 @@ +/** + * 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 new file mode 100644 index 0000000..0b1971b --- /dev/null +++ b/src/services/graphql/topics.ts @@ -0,0 +1,164 @@ +import { PageLink, Slug, Topic } from '@ts/types/app'; +import { + RawArticle, + RawTopic, + RawTopicPreview, + TotalItems, +} from '@ts/types/raw-data'; +import { getImageFromRawData } from '@utils/helpers/images'; +import { getPageLinkFromRawData } from '@utils/helpers/pages'; +import { EdgesResponse, EdgesVars, fetchAPI, getAPIUrl } 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>({ + api: getAPIUrl(), + query: totalTopicsQuery, + }); + + return response.topics.pageInfo.total; +}; + +/** + * Retrieve the given number of topics from API. + * + * @param {EdgesVars} props - An object of GraphQL variables. + * @returns {Promise<EdgesResponse<RawTopicPreview>>} The topics data. + */ +export const getTopicsPreview = async ( + props: EdgesVars +): Promise<EdgesResponse<RawTopicPreview>> => { + const response = await fetchAPI<RawTopicPreview, typeof topicsListQuery>({ + api: getAPIUrl(), + 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 = (data: RawTopic): 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) { + post.acfPosts.postsInThematic.forEach((thematic) => + thematics.push(getPageLinkFromRawData(thematic, 'thematic')) + ); + } + }); + + const thematicsIds = thematics.map((thematic) => thematic.id); + const uniqueThematics = thematics.filter( + ({ id }, index) => !thematicsIds.includes(id, index + 1) + ); + const sortThematicByName = (a: PageLink, b: PageLink) => { + var nameA = a.name.toUpperCase(); // ignore upper and lowercase + var nameB = b.name.toUpperCase(); // ignore upper and lowercase + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + + // names must be equal + return 0; + }; + + return uniqueThematics.sort(sortThematicByName); + }; + + return { + content: contentParts.afterMore, + id: databaseId, + intro: contentParts.beforeMore, + meta: { + articles: acfTopics.postsInTopic.map((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>({ + api: getAPIUrl(), + 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>({ + api: getAPIUrl(), + query: topicsSlugQuery, + variables: { first: totalTopics }, + }); + + return response.topics.edges.map((edge) => edge.node.slug); +}; |
