diff options
Diffstat (limited to 'src/utils')
36 files changed, 1613 insertions, 522 deletions
diff --git a/src/utils/config.ts b/src/utils/config.ts index 874a24c..61a46b4 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -18,8 +18,9 @@ export const settings = { }, copyright: { startYear: '2012', - endYear: new Date().getFullYear(), + endYear: new Date().getFullYear().toString(), }, + email: process.env.APP_AUTHOR_EMAIL || '', locales: { defaultLocale: 'fr', defaultCountry: 'FR', diff --git a/src/utils/helpers/author.ts b/src/utils/helpers/author.ts new file mode 100644 index 0000000..40743ca --- /dev/null +++ b/src/utils/helpers/author.ts @@ -0,0 +1,32 @@ +import { type Author, type ContentKind } from '@ts/types/app'; +import { type RawAuthor } from '@ts/types/raw-data'; + +/** + * 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/dates.ts b/src/utils/helpers/dates.ts new file mode 100644 index 0000000..cb56ad2 --- /dev/null +++ b/src/utils/helpers/dates.ts @@ -0,0 +1,40 @@ +import { settings } from '@utils/config'; + +/** + * Format a date based on a locale. + * + * @param {string} date - The date. + * @param {string} [locale] - A locale. + * @returns {string} The locale date string. + */ +export const getFormattedDate = ( + date: string, + locale: string = settings.locales.defaultLocale +): string => { + const dateOptions: Intl.DateTimeFormatOptions = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + + return new Date(date).toLocaleDateString(locale, dateOptions); +}; + +/** + * Format a time based on a locale. + * + * @param {string} time - The time. + * @param {string} [locale] - A locale. + * @returns {string} The locale time string. + */ +export const getFormattedTime = ( + time: string, + locale: string = settings.locales.defaultLocale +): string => { + const formattedTime = new Date(time).toLocaleTimeString(locale, { + hour: 'numeric', + minute: 'numeric', + }); + + return locale === 'fr' ? formattedTime.replace(':', 'h') : formattedTime; +}; diff --git a/src/utils/helpers/format.ts b/src/utils/helpers/format.ts deleted file mode 100644 index dd35868..0000000 --- a/src/utils/helpers/format.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { ParamsIds, ParamsSlug, Slug } from '@ts/types/app'; -import { - Article, - ArticlePreview, - RawArticle, - RawArticlePreview, -} from '@ts/types/articles'; -import { Comment, RawComment } from '@ts/types/comments'; -import { - RawTopic, - RawTopicPreview, - RawThematic, - Topic, - TopicPreview, - Thematic, -} from '@ts/types/taxonomies'; - -/** - * Format a post preview from RawArticlePreview to ArticlePreview type. - * @param rawPost - A post preview coming from WP GraphQL. - * @returns A formatted post preview. - */ -export const getFormattedPostPreview = (rawPost: RawArticlePreview) => { - const { - acfPosts, - commentCount, - contentParts, - date, - featuredImage, - id, - info, - modified, - slug, - title, - } = rawPost; - - const dates = { - publication: date, - update: modified, - }; - - const topics = acfPosts.postsInTopic ? acfPosts.postsInTopic : []; - const thematics = acfPosts.postsInThematic ? acfPosts.postsInThematic : []; - - const formattedPost: ArticlePreview = { - commentCount, - dates, - featuredImage: featuredImage ? featuredImage.node : null, - id, - info, - intro: contentParts.beforeMore, - slug, - topics, - thematics, - title, - }; - - return formattedPost; -}; - -/** - * Format an array of posts list from RawArticlePreview to ArticlePreview type. - * @param rawPosts - A posts list coming from WP GraphQL. - * @returns A formatted posts list. - */ -export const getFormattedPostsList = ( - rawPosts: RawArticlePreview[] -): ArticlePreview[] => { - return rawPosts - .filter((post) => Object.getOwnPropertyNames(post).length > 0) - .map((post) => { - return getFormattedPostPreview(post); - }); -}; - -/** - * Format a topic from RawTopic to Topic type. - * @param rawTopic - A topic coming from WP GraphQL. - * @returns A formatted topic. - */ -export const getFormattedTopic = (rawTopic: RawTopic): Topic => { - const { - acfTopics, - contentParts, - databaseId, - date, - featuredImage, - id, - info, - modified, - seo, - title, - } = rawTopic; - - const dates = { - publication: date, - update: modified, - }; - - const posts = getFormattedPostsList(acfTopics.postsInTopic); - - const formattedTopic: Topic = { - content: contentParts.afterMore, - databaseId, - dates, - featuredImage: featuredImage ? featuredImage.node : null, - id, - info, - intro: contentParts.beforeMore, - officialWebsite: acfTopics.officialWebsite, - posts, - seo, - title, - }; - - return formattedTopic; -}; - -/** - * Format a thematic from RawThematic to Thematic type. - * @param rawThematic - A thematic coming from wP GraphQL. - * @returns A formatted thematic. - */ -export const getFormattedThematic = (rawThematic: RawThematic): Thematic => { - const { - acfThematics, - contentParts, - databaseId, - date, - id, - info, - modified, - seo, - title, - } = rawThematic; - - const dates = { - publication: date, - update: modified, - }; - - const posts = getFormattedPostsList(acfThematics.postsInThematic); - - const formattedThematic: Thematic = { - content: contentParts.afterMore, - databaseId, - dates, - id, - info, - intro: contentParts.beforeMore, - posts, - seo, - title, - }; - - return formattedThematic; -}; - -/** - * Format a comments list from RawComment to Comment type. - * @param rawComments - A comments list coming from WP GraphQL. - * @returns A formatted comments list. - */ -export const getFormattedComments = (rawComments: RawComment[]): Comment[] => { - const formattedComments: Comment[] = rawComments.map((comment) => { - const formattedComment: Comment = { - ...comment, - author: comment.author.node, - replies: [], - }; - - return formattedComment; - }); - - return formattedComments; -}; - -/** - * Create a comments tree with replies. - * @param comments - A flatten comments list. - * @returns An array of comments with replies. - */ -export const buildCommentsTree = (comments: Comment[]) => { - type CommentsHashTable = { - [key: string]: Comment; - }; - - const hashTable: CommentsHashTable = Object.create(null); - const commentsTree: Comment[] = []; - - comments.forEach( - (comment) => (hashTable[comment.databaseId] = { ...comment, replies: [] }) - ); - - comments.forEach((comment) => { - if (!comment.parentDatabaseId) { - commentsTree.push(hashTable[comment.databaseId]); - } else { - hashTable[comment.parentDatabaseId].replies.push( - hashTable[comment.databaseId] - ); - } - }); - - return commentsTree; -}; - -export const getFormattedTopicsPreview = ( - topics: RawTopicPreview[] -): TopicPreview[] => { - const formattedTopics: TopicPreview[] = topics.map((topic) => { - return { - ...topic, - featuredImage: topic.featuredImage ? topic.featuredImage.node : null, - }; - }); - - return formattedTopics; -}; - -/** - * Format an article from RawArticle to Article type. - * @param rawPost - An article coming from WP GraphQL. - * @returns A formatted article. - */ -export const getFormattedPost = (rawPost: RawArticle): Article => { - const { - acfPosts, - author, - commentCount, - contentParts, - databaseId, - date, - featuredImage, - id, - info, - modified, - seo, - title, - } = rawPost; - - const dates = { - publication: date, - update: modified, - }; - - const topics = acfPosts.postsInTopic - ? getFormattedTopicsPreview(acfPosts.postsInTopic) - : []; - - const formattedPost: Article = { - author: author.node, - commentCount, - content: contentParts.afterMore, - databaseId, - dates, - featuredImage: featuredImage ? featuredImage.node : null, - id, - info, - intro: contentParts.beforeMore, - seo, - topics, - thematics: acfPosts.postsInThematic ? acfPosts.postsInThematic : [], - title, - }; - - return formattedPost; -}; - -/** - * Converts a date to a string by using the specified locale. - * @param {string} date The date. - * @param {string} locale A locale. - * @returns {string} The formatted date to locale date string. - */ -export const getFormattedDate = (date: string, locale: string) => { - const dateOptions: Intl.DateTimeFormatOptions = { - day: 'numeric', - month: 'long', - year: 'numeric', - }; - - return new Date(date).toLocaleDateString(locale, dateOptions); -}; - -/** - * Convert an array of slugs to an array of params with slug. - * @param {Slug} array - An array of object with slug. - * @returns {ParamsSlug} An array of params with slug. - */ -export const getFormattedPaths = (array: Slug[]): ParamsSlug[] => { - return array.map((object) => { - return { params: { slug: object.slug } }; - }); -}; - -/** - * Convert a number of pages to an array of params with ids. - * @param {number} totalPages - The total pages. - * @returns {ParamsIds} An array of params with ids. - */ -export const getFormattedPageNumbers = (totalPages: number): ParamsIds[] => { - const paths = []; - - for (let i = 1; i <= totalPages; i++) { - paths.push({ params: { id: `${i}` } }); - } - - return paths; -}; diff --git a/src/utils/helpers/i18n.ts b/src/utils/helpers/i18n.ts index c4734ad..5d19c8c 100644 --- a/src/utils/helpers/i18n.ts +++ b/src/utils/helpers/i18n.ts @@ -3,7 +3,7 @@ import { settings } from '@utils/config'; import { readFile } from 'fs/promises'; import path from 'path'; -type Messages = { [key: string]: string }; +export type Messages = { [key: string]: string }; export const defaultLocale = settings.locales.defaultLocale; diff --git a/src/utils/helpers/images.ts b/src/utils/helpers/images.ts new file mode 100644 index 0000000..30bb8be --- /dev/null +++ b/src/utils/helpers/images.ts @@ -0,0 +1,18 @@ +import { Image } from '@ts/types/app'; +import { RawCover } from '@ts/types/raw-data'; + +/** + * 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/pages.ts b/src/utils/helpers/pages.ts new file mode 100644 index 0000000..773d454 --- /dev/null +++ b/src/utils/helpers/pages.ts @@ -0,0 +1,85 @@ +import { type Post } from '@components/organisms/layout/posts-list'; +import { type LinksListItems } from '@components/organisms/widgets/links-list-widget'; +import { type EdgesResponse } from '@services/graphql/api'; +import { getArticleFromRawData } from '@services/graphql/articles'; +import { type Article, type PageLink } from '@ts/types/app'; +import { + type RawArticle, + type RawThematicPreview, + type RawTopicPreview, +} from '@ts/types/raw-data'; +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' ? '/thematique/' : '/sujet/'; + + return { + id: databaseId, + logo: featuredImage ? getImageFromRawData(featuredImage?.node) : undefined, + name: title, + url: `${baseUrl}${slug}`, + }; +}; + +/** + * Convert page link data to an array of links items. + * + * @param {PageLink[]} links - An array of page links. + * @returns {LinksListItem[]} An array of links items. + */ +export const getLinksListItems = (links: PageLink[]): LinksListItems[] => { + return links.map((link) => { + return { + name: link.name, + url: link.url, + }; + }); +}; + +/** + * Retrieve the posts list with the article URL. + * + * @param {Article[]} posts - An array of articles. + * @returns {Post[]} An array of posts with full article URL. + */ +export const getPostsWithUrl = (posts: Article[]): Post[] => { + return posts.map((post) => { + return { + ...post, + url: `/article/${post.slug}`, + }; + }); +}; + +/** + * Retrieve the posts list from raw data. + * + * @param {EdgesResponse<RawArticle>[]} rawData - The raw data. + * @returns {Post[]} An array of posts. + */ +export const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => { + const articlesList: RawArticle[] = []; + rawData.forEach((articleData) => + articleData.edges.forEach((edge) => { + articlesList.push(edge.node); + }) + ); + + return getPostsWithUrl( + articlesList.map((article) => getArticleFromRawData(article)) + ); +}; diff --git a/src/utils/helpers/prism.ts b/src/utils/helpers/prism.ts deleted file mode 100644 index a5f5787..0000000 --- a/src/utils/helpers/prism.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Check if the current block has a defined language. - * @param classList - A list of class. - * @returns {boolean} - True if a class starts with "language-". - */ -const isLanguageBlock = (classList: DOMTokenList) => { - const classes = Array.from(classList); - return classes.some((className) => /language-.*/.test(className)); -}; - -/** - * Add automatically some classes and attributes for PrismJs. - * - * These classes and attributes are needed by Prism or to customize comments. - */ -export const addPrismClasses = () => { - const preTags = document.getElementsByTagName('pre'); - - Array.from(preTags).forEach((preTag) => { - if (!isLanguageBlock(preTag.classList)) return; - - preTag.classList.add('match-braces'); - - if (preTag.classList.contains('filter-output')) { - preTag.setAttribute('data-filter-output', '#output#'); - } - - if (preTag.classList.contains('language-bash')) { - preTag.classList.add('command-line'); - } else if (!preTag.classList.contains('language-diff')) { - preTag.classList.add('line-numbers'); - } - }); -}; diff --git a/src/utils/helpers/projects.ts b/src/utils/helpers/projects.ts index 1612dae..a0f0c04 100644 --- a/src/utils/helpers/projects.ts +++ b/src/utils/helpers/projects.ts @@ -1,36 +1,55 @@ -import { Project, ProjectMeta } from '@ts/types/app'; +import { ProjectCard, ProjectPreview } from '@ts/types/app'; +import { MDXProjectMeta } from '@ts/types/mdx'; import { readdirSync } from 'fs'; import path from 'path'; /** - * Retrieve project's data by id. - * @param {string} id - The filename without extension. - * @returns {Promise<Project>} - The project data. + * Retrieve all the projects filename. + * + * @returns {string[]} An array of filenames. */ -export const getProjectData = async (id: string): Promise<Project> => { +export const getProjectFilenames = (): string[] => { + const projectsDirectory = path.join(process.cwd(), 'src/content/projects'); + const filenames = readdirSync(projectsDirectory); + + return filenames.map((filename) => filename.replace(/\.mdx$/, '')); +}; + +/** + * Retrieve the data of a project by filename. + * + * @param {string} filename - The project filename. + * @returns {Promise<ProjectPreview>} + */ +export const getProjectData = async ( + filename: string +): Promise<ProjectPreview> => { try { const { - intro, meta, - seo, - tagline, }: { - intro: string; - meta: ProjectMeta & { title: string }; - seo: { title: string; description: string }; - tagline?: string; - } = await import(`../../content/projects/${id}.mdx`); + meta: MDXProjectMeta; + } = await import(`../../content/projects/${filename}.mdx`); - const { title, ...onlyMeta } = meta; + const { dates, intro, title, ...projectMeta } = meta; + const { publication, update } = dates; + const cover = await import(`../../../public/projects/${filename}.jpg`); return { - id, - intro: intro || '', - meta: onlyMeta || {}, - slug: id, - title, - seo: seo || {}, - tagline: tagline || '', + id: filename, + intro, + meta: { + ...projectMeta, + dates: { publication, update }, + // Dynamic import source does not work so I use it only to get sizes + cover: { + ...cover.default, + alt: `${title} image`, + src: `/projects/${filename}.jpg`, + }, + }, + slug: filename, + title: title, }; } catch (err) { console.error(err); @@ -39,48 +58,44 @@ export const getProjectData = async (id: string): Promise<Project> => { }; /** - * Retrieve the projects data from filenames. - * @param {string[]} filenames - An array of filenames. - * @returns {Promise<Project[]>} An array of projects with meta. + * Retrieve all the projects data using filenames. + * + * @param {string[]} filenames - The filenames without extension. + * @returns {Promise<ProjectCard[]>} - An array of projects data. */ -const getProjectsWithMeta = async (filenames: string[]): Promise<Project[]> => { +export const getProjectsData = async ( + filenames: string[] +): Promise<ProjectCard[]> => { return Promise.all( filenames.map(async (filename) => { - return getProjectData(filename); + const { id, meta, slug, title } = await getProjectData(filename); + const { cover, dates, tagline, technologies } = meta; + return { id, meta: { cover, dates, tagline, technologies }, slug, title }; }) ); }; /** * Method to sort an array of projects by publication date. - * @param {Project} a - A single project. - * @param {Project} b - A single project. + * + * @param {ProjectCard} a - A single project. + * @param {ProjectCard} b - A single project. * @returns The result used by Array.sort() method: 1 || -1 || 0. */ -const sortProjectByPublicationDate = (a: Project, b: Project) => { - if (a.meta.publishedOn < b.meta.publishedOn) return 1; - if (a.meta.publishedOn > b.meta.publishedOn) return -1; +const sortProjectsByPublicationDate = (a: ProjectCard, b: ProjectCard) => { + if (a.meta.dates.publication < b.meta.dates.publication) return 1; + if (a.meta.dates.publication > b.meta.dates.publication) return -1; return 0; }; /** - * Retrieve all the projects filename. - * @returns {string[]} An array of filenames. - */ -export const getAllProjectsFilename = (): string[] => { - const projectsDirectory = path.join(process.cwd(), 'src/content/projects'); - const filenames = readdirSync(projectsDirectory); - - return filenames.map((filename) => filename.replace(/\.mdx$/, '')); -}; - -/** * Retrieve all projects in content folder sorted by publication date. - * @returns {Promise<Project[]>} An array of projects. + * + * @returns {Promise<ProjectCard[]>} An array of projects. */ -export const getSortedProjects = async (): Promise<Project[]> => { - const filenames = getAllProjectsFilename(); - const projects = await getProjectsWithMeta(filenames); +export const getProjectsCard = async (): Promise<ProjectCard[]> => { + const filenames = getProjectFilenames(); + const projects = await getProjectsData(filenames); - return [...projects].sort(sortProjectByPublicationDate); + return [...projects].sort(sortProjectsByPublicationDate); }; diff --git a/src/utils/helpers/rss.ts b/src/utils/helpers/rss.ts index 10a8e77..8ee774c 100644 --- a/src/utils/helpers/rss.ts +++ b/src/utils/helpers/rss.ts @@ -1,20 +1,35 @@ -import { getPostsTotal, getPublishedPosts } from '@services/graphql/queries'; -import { ArticlePreview } from '@ts/types/articles'; -import { PostsList } from '@ts/types/blog'; +import { + getArticleFromRawData, + getArticles, + getTotalArticles, +} from '@services/graphql/articles'; +import { Article } from '@ts/types/app'; import { settings } from '@utils/config'; import { Feed } from 'feed'; -const getAllPosts = async (): Promise<ArticlePreview[]> => { - const totalPosts = await getPostsTotal(); - const posts: ArticlePreview[] = []; +/** + * Retrieve the data for all the articles. + * + * @returns {Promise<Article[]>} - All the articles. + */ +const getAllArticles = async (): Promise<Article[]> => { + const totalArticles = await getTotalArticles(); + const rawArticles = await getArticles({ first: totalArticles }); + const articles: Article[] = []; - const postsList: PostsList = await getPublishedPosts({ first: totalPosts }); - posts.push(...postsList.posts); + rawArticles.edges.forEach((edge) => + articles.push(getArticleFromRawData(edge.node)) + ); - return posts; + return articles; }; -export const generateFeed = async () => { +/** + * Generate a new feed. + * + * @returns {Promise<Feed>} - The feed. + */ +export const generateFeed = async (): Promise<Feed> => { const author = { name: settings.name, email: process.env.APP_AUTHOR_EMAIL, @@ -38,16 +53,16 @@ export const generateFeed = async () => { title, }); - const posts = await getAllPosts(); + const articles = await getAllArticles(); - posts.forEach((post) => { + articles.forEach((article) => { feed.addItem({ - content: post.intro, - date: new Date(post.dates.publication), - description: post.intro, - id: post.id, - link: `${settings.url}/article/${post.slug}`, - title: post.title, + content: article.intro, + date: new Date(article.meta!.dates.publication), + description: article.intro, + id: `${article.id}`, + link: `${settings.url}/article/${article.slug}`, + title: article.title, }); }); diff --git a/src/utils/helpers/schema-org.ts b/src/utils/helpers/schema-org.ts new file mode 100644 index 0000000..cdace00 --- /dev/null +++ b/src/utils/helpers/schema-org.ts @@ -0,0 +1,224 @@ +import { Dates } from '@ts/types/app'; +import { settings } from '@utils/config'; +import { + AboutPage, + Article, + Blog, + BlogPosting, + ContactPage, + Graph, + WebPage, +} from 'schema-dts'; + +export type GetBlogSchemaProps = { + /** + * True if the page is part of the blog. + */ + isSinglePage: boolean; + /** + * The page locale. + */ + locale: string; + /** + * The page slug with a leading slash. + */ + slug: string; +}; + +/** + * Retrieve the JSON for Blog schema. + * + * @param props - The page data. + * @returns {Blog} The JSON for Blog schema. + */ +export const getBlogSchema = ({ + isSinglePage, + locale, + slug, +}: GetBlogSchemaProps): Blog => { + return { + '@id': `${settings.url}/#blog`, + '@type': 'Blog', + author: { '@id': `${settings.url}/#branding` }, + creator: { '@id': `${settings.url}/#branding` }, + editor: { '@id': `${settings.url}/#branding` }, + blogPost: isSinglePage ? { '@id': `${settings.url}/#article` } : undefined, + inLanguage: locale, + isPartOf: isSinglePage + ? { + '@id': `${settings.url}${slug}`, + } + : undefined, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: isSinglePage + ? undefined + : { '@id': `${settings.url}${slug}` }, + }; +}; + +export type SinglePageSchemaReturn = { + about: AboutPage; + contact: ContactPage; + page: Article; + post: BlogPosting; +}; + +export type SinglePageSchemaKind = keyof SinglePageSchemaReturn; + +export type GetSinglePageSchemaProps<T extends SinglePageSchemaKind> = { + /** + * The number of comments. + */ + commentsCount?: number; + /** + * The page content. + */ + content?: string; + /** + * The url of the cover. + */ + cover?: string; + /** + * The page dates. + */ + dates: Dates; + /** + * The page description. + */ + description: string; + /** + * The page id. + */ + id: string; + /** + * The page kind. + */ + kind: T; + /** + * The page locale. + */ + locale: string; + /** + * The page slug with a leading slash. + */ + slug: string; + /** + * The page title. + */ + title: string; +}; + +/** + * Retrieve the JSON schema depending on the page kind. + * + * @param props - The page data. + * @returns {SinglePageSchemaReturn[T]} - Either AboutPage, ContactPage, Article or BlogPosting schema. + */ +export const getSinglePageSchema = <T extends SinglePageSchemaKind>({ + commentsCount, + content, + cover, + dates, + description, + id, + kind, + locale, + title, + slug, +}: GetSinglePageSchemaProps<T>): SinglePageSchemaReturn[T] => { + const publicationDate = new Date(dates.publication); + const updateDate = dates.update ? new Date(dates.update) : undefined; + const singlePageSchemaType = { + about: 'AboutPage', + contact: 'ContactPage', + page: 'Article', + post: 'BlogPosting', + }; + + return { + '@id': `${settings.url}/#${id}`, + '@type': singlePageSchemaType[kind], + name: title, + description, + articleBody: content, + author: { '@id': `${settings.url}/#branding` }, + commentCount: commentsCount, + copyrightYear: publicationDate.getFullYear(), + creator: { '@id': `${settings.url}/#branding` }, + dateCreated: publicationDate.toISOString(), + dateModified: updateDate && updateDate.toISOString(), + datePublished: publicationDate.toISOString(), + editor: { '@id': `${settings.url}/#branding` }, + headline: title, + image: cover, + inLanguage: locale, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + thumbnailUrl: cover, + isPartOf: + kind === 'post' + ? { + '@id': `${settings.url}/blog`, + } + : undefined, + mainEntityOfPage: { '@id': `${settings.url}${slug}` }, + } as SinglePageSchemaReturn[T]; +}; + +export type GetWebPageSchemaProps = { + /** + * The page description. + */ + description: string; + /** + * The page locale. + */ + locale: string; + /** + * The page slug. + */ + slug: string; + /** + * The page title. + */ + title: string; + /** + * The page last update. + */ + updateDate?: string; +}; + +/** + * Retrieve the JSON for WebPage schema. + * + * @param props - The page data. + * @returns {WebPage} The JSON for WebPage schema. + */ +export const getWebPageSchema = ({ + description, + locale, + slug, + title, + updateDate, +}: GetWebPageSchemaProps): WebPage => { + return { + '@id': `${settings.url}${slug}`, + '@type': 'WebPage', + breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, + lastReviewed: updateDate, + name: title, + description: description, + inLanguage: locale, + reviewedBy: { '@id': `${settings.url}/#branding` }, + url: `${settings.url}${slug}`, + isPartOf: { + '@id': `${settings.url}`, + }, + }; +}; + +export const getSchemaJson = (graphs: Graph['@graph']): Graph => { + return { + '@context': 'https://schema.org', + '@graph': graphs, + }; +}; diff --git a/src/utils/helpers/slugify.ts b/src/utils/helpers/slugify.ts deleted file mode 100644 index 55ff583..0000000 --- a/src/utils/helpers/slugify.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Convert a text into a slug or id. - * https://gist.github.com/codeguy/6684588#gistcomment-3332719 - * - * @param {string} text Text to slugify. - */ -export const slugify = (text: string) => { - return text - .toString() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .trim() - .replace(/\s+/g, '-') - .replace(/[^\w\-]+/g, '-') - .replace(/\-\-+/g, '-') - .replace(/(^-)|(-$)/g, ''); -}; diff --git a/src/utils/helpers/sort.ts b/src/utils/helpers/sort.ts deleted file mode 100644 index c1ee35d..0000000 --- a/src/utils/helpers/sort.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ArticlePreview } from '@ts/types/articles'; -import { PostsList } from '@ts/types/blog'; - -type YearCollection = { - [key: string]: ArticlePreview[]; -}; - -export const sortPostsByYear = (data: PostsList[]) => { - const yearCollection: YearCollection = {}; - - data.forEach((page) => { - page.posts.forEach((post) => { - const postYear = new Date(post.dates.publication) - .getFullYear() - .toString(); - yearCollection[postYear] = [...(yearCollection[postYear] || []), post]; - }); - }); - - return yearCollection; -}; diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts new file mode 100644 index 0000000..1af0ca2 --- /dev/null +++ b/src/utils/helpers/strings.ts @@ -0,0 +1,39 @@ +/** + * Convert a text into a slug or id. + * https://gist.github.com/codeguy/6684588#gistcomment-3332719 + * + * @param {string} text - A text to slugify. + * @returns {string} The slug. + */ +export const slugify = (text: string): string => { + return text + .toString() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^\w\-]+/g, '-') + .replace(/\-\-+/g, '-') + .replace(/(^-)|(-$)/g, ''); +}; + +/** + * Capitalize the first letter of a string. + * + * @param {string} text - A text to capitalize. + * @returns {string} The capitalized text. + */ +export const capitalize = (text: string): string => { + return text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase()); +}; + +/** + * Convert a text from kebab case (foo-bar) to camel case (fooBar). + * + * @param {string} text - A text to transform. + * @returns {string} The text in camel case. + */ +export const fromKebabCaseToCamelCase = (text: string): string => { + return text.replace(/-./g, (x) => x[1].toUpperCase()); +}; diff --git a/src/utils/hooks/use-add-classname.tsx b/src/utils/hooks/use-add-classname.tsx new file mode 100644 index 0000000..0584084 --- /dev/null +++ b/src/utils/hooks/use-add-classname.tsx @@ -0,0 +1,34 @@ +import { useCallback, useEffect } from 'react'; + +export type UseAddClassNameProps = { + className: string; + element?: HTMLElement; + elements?: NodeListOf<HTMLElement> | HTMLElement[]; +}; + +/** + * Add className to the given element(s). + * + * @param {UseAddClassNameProps} props - An object with classnames and one or more elements. + */ +const useAddClassName = ({ + className, + element, + elements, +}: UseAddClassNameProps) => { + const classNames = className.split(' ').filter((string) => string !== ''); + + const setClassName = useCallback( + (el: HTMLElement) => { + el.classList.add(...classNames); + }, + [classNames] + ); + + useEffect(() => { + if (element) setClassName(element); + if (elements && elements.length > 0) elements.forEach(setClassName); + }, [element, elements, setClassName]); +}; + +export default useAddClassName; diff --git a/src/utils/hooks/use-attributes.tsx b/src/utils/hooks/use-attributes.tsx new file mode 100644 index 0000000..6d18048 --- /dev/null +++ b/src/utils/hooks/use-attributes.tsx @@ -0,0 +1,52 @@ +import { fromKebabCaseToCamelCase } from '@utils/helpers/strings'; +import { useCallback, useEffect } from 'react'; + +export type useAttributesProps = { + /** + * An HTML element. + */ + element?: HTMLElement; + /** + * A node list of HTML Element. + */ + elements?: NodeListOf<HTMLElement> | HTMLElement[]; + /** + * The attribute name. + */ + attribute: string; + /** + * The attribute value. + */ + value: string; +}; + +/** + * Set HTML attributes to the given element or to the HTML document. + * + * @param props - An object with element, attribute name and value. + */ +const useAttributes = ({ + element, + elements, + attribute, + value, +}: useAttributesProps) => { + const setAttribute = useCallback( + (el: HTMLElement) => { + if (attribute.startsWith('data')) { + el.setAttribute(attribute, value); + } else { + const camelCaseAttribute = fromKebabCaseToCamelCase(attribute); + el.dataset[camelCaseAttribute] = value; + } + }, + [attribute, value] + ); + + useEffect(() => { + if (element) setAttribute(element); + if (elements && elements.length > 0) elements.forEach(setAttribute); + }, [element, elements, setAttribute]); +}; + +export default useAttributes; diff --git a/src/utils/hooks/use-breadcrumb.tsx b/src/utils/hooks/use-breadcrumb.tsx new file mode 100644 index 0000000..130ebf1 --- /dev/null +++ b/src/utils/hooks/use-breadcrumb.tsx @@ -0,0 +1,107 @@ +import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb'; +import { slugify } from '@utils/helpers/strings'; +import { useIntl } from 'react-intl'; +import { BreadcrumbList } from 'schema-dts'; +import useSettings from './use-settings'; + +export type useBreadcrumbProps = { + /** + * The current page title. + */ + title: string; + /** + * The current page url. + */ + url: string; +}; + +export type useBreadcrumbReturn = { + /** + * The breadcrumb items. + */ + items: BreadcrumbItem[]; + /** + * The breadcrumb JSON schema. + */ + schema: BreadcrumbList['itemListElement'][]; +}; + +/** + * Retrieve the breadcrumb items. + * + * @param {useBreadcrumbProps} props - An object (the current page title & url). + * @returns {useBreadcrumbReturn} The breadcrumb items and its JSON schema. + */ +const useBreadcrumb = ({ + title, + url, +}: useBreadcrumbProps): useBreadcrumbReturn => { + const intl = useIntl(); + const { website } = useSettings(); + const isArticle = url.startsWith('/article/'); + const isHome = url === '/'; + const isPageNumber = url.includes('/page/'); + const isProject = url.startsWith('/projets/'); + const isSearch = url.startsWith('/recherche'); + const isThematic = url.startsWith('/thematique/'); + const isTopic = url.startsWith('/sujet/'); + + const homeLabel = intl.formatMessage({ + defaultMessage: 'Home', + description: 'Breadcrumb: home label', + id: 'j5k9Fe', + }); + const items: BreadcrumbItem[] = [{ id: 'home', name: homeLabel, url: '/' }]; + const schema: BreadcrumbList['itemListElement'][] = [ + { + '@type': 'ListItem', + position: 1, + name: homeLabel, + item: website.url, + }, + ]; + + if (isHome) return { items, schema }; + + if (isArticle || isPageNumber || isSearch || isThematic || isTopic) { + const blogLabel = intl.formatMessage({ + defaultMessage: 'Blog', + description: 'Breadcrumb: blog label', + id: 'Es52wh', + }); + items.push({ id: 'blog', name: blogLabel, url: '/blog' }); + schema.push({ + '@type': 'ListItem', + position: 2, + name: blogLabel, + item: `${website.url}/blog`, + }); + } + + if (isProject) { + const projectsLabel = intl.formatMessage({ + defaultMessage: 'Projects', + description: 'Breadcrumb: projects label', + id: '28GZdv', + }); + items.push({ id: 'blog', name: projectsLabel, url: '/projets' }); + schema.push({ + '@type': 'ListItem', + position: 2, + name: projectsLabel, + item: `${website.url}/projets`, + }); + } + + items.push({ id: slugify(title), name: title, url }); + schema.push({ + '@type': 'ListItem', + position: schema.length + 1, + name: title, + item: `${website.url}${url}`, + }); + + return { items, schema }; +}; + +export default useBreadcrumb; diff --git a/src/utils/hooks/use-click-outside.tsx b/src/utils/hooks/use-click-outside.tsx new file mode 100644 index 0000000..cead98b --- /dev/null +++ b/src/utils/hooks/use-click-outside.tsx @@ -0,0 +1,46 @@ +import { RefObject, useCallback, useEffect } from 'react'; + +/** + * Listen for click/focus outside an element and execute the given callback. + * + * @param el - A React reference to an element. + * @param callback - A callback function to execute on click outside. + */ +const useClickOutside = ( + el: RefObject<HTMLElement>, + callback: (target: EventTarget) => void +) => { + /** + * Check if an event target is outside an element. + * + * @param {RefObject<HTMLElement>} ref - A React reference object. + * @param {EventTarget} target - An event target. + * @returns {boolean} True if the event target is outside the ref object. + */ + const isTargetOutside = ( + ref: RefObject<HTMLElement>, + target: EventTarget + ): boolean => { + if (!ref.current) return false; + return !ref.current.contains(target as Node); + }; + + const handleEvent = useCallback( + (e: MouseEvent | FocusEvent) => { + if (e.target && isTargetOutside(el, e.target)) callback(e.target); + }, + [el, callback] + ); + + useEffect(() => { + document.addEventListener('mousedown', handleEvent); + document.addEventListener('focusin', handleEvent); + + return () => { + document.removeEventListener('mousedown', handleEvent); + document.removeEventListener('focusin', handleEvent); + }; + }, [handleEvent]); +}; + +export default useClickOutside; diff --git a/src/utils/hooks/use-data-from-api.tsx b/src/utils/hooks/use-data-from-api.tsx new file mode 100644 index 0000000..7082941 --- /dev/null +++ b/src/utils/hooks/use-data-from-api.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +/** + * Fetch data from an API. + * + * This hook is a wrapper to `setState` + `useEffect`. + * + * @param fetcher - A function to fetch data from API. + * @returns {T | undefined} The requested data. + */ +const useDataFromAPI = <T extends unknown>( + fetcher: () => Promise<T> +): T | undefined => { + const [data, setData] = useState<T>(); + + useEffect(() => { + fetcher().then((apiData) => setData(apiData)); + }, [fetcher]); + + return data; +}; + +export default useDataFromAPI; diff --git a/src/utils/hooks/useGithubApi.tsx b/src/utils/hooks/use-github-api.tsx index 4b0b3b2..edff974 100644 --- a/src/utils/hooks/useGithubApi.tsx +++ b/src/utils/hooks/use-github-api.tsx @@ -1,15 +1,22 @@ -import { RepoData } from '@ts/types/repos'; +import { SWRResult } from '@ts/types/swr'; import useSWR, { Fetcher } from 'swr'; +export type RepoData = { + created_at: string; + updated_at: string; + stargazers_count: number; +}; + const fetcher: Fetcher<RepoData, string> = (...args) => fetch(...args).then((res) => res.json()); /** * Retrieve data from Github API. - * @param repo The repo name. Format: "User/project-slug". - * @returns {object} The data and two booleans to determine if is loading/error. + * + * @param repo - The Github repo (`owner/repo-name`). + * @returns The repository data. */ -const useGithubApi = (repo: string) => { +const useGithubApi = (repo: string): SWRResult<RepoData> => { const apiUrl = repo ? `https://api.github.com/repos/${repo}` : null; const { data, error } = useSWR<RepoData>(apiUrl, fetcher); diff --git a/src/utils/hooks/useHeadingsTree.tsx b/src/utils/hooks/use-headings-tree.tsx index f2be406..4646b4a 100644 --- a/src/utils/hooks/useHeadingsTree.tsx +++ b/src/utils/hooks/use-headings-tree.tsx @@ -1,40 +1,71 @@ -import { Heading } from '@ts/types/app'; -import { slugify } from '@utils/helpers/slugify'; -import { useRouter } from 'next/router'; +import { slugify } from '@utils/helpers/strings'; import { useCallback, useEffect, useMemo, useState } from 'react'; -const useHeadingsTree = (wrapper: string) => { - const router = useRouter(); +export type Heading = { + /** + * The heading depth. + */ + depth: number; + /** + * The heading id. + */ + id: string; + /** + * The heading children. + */ + children: Heading[]; + /** + * The heading title. + */ + title: string; +}; + +/** + * Get the headings tree of the given HTML element. + * + * @param {HTMLElement} wrapper - An HTML element that contains the headings. + * @returns {Heading[]} The headings tree. + */ +const useHeadingsTree = (wrapper: HTMLElement): Heading[] => { const depths = useMemo(() => ['h2', 'h3', 'h4', 'h5', 'h6'], []); const [allHeadings, setAllHeadings] = useState<NodeListOf<HTMLHeadingElement>>(); + const [headingsTree, setHeadingsTree] = useState<Heading[]>([]); useEffect(() => { - const query = depths - .map((depth) => `${wrapper} > *:not(aside, #comments) ${depth}`) - .join(', '); + const query = depths.join(', '); const result: NodeListOf<HTMLHeadingElement> = - document.querySelectorAll(query); + wrapper.querySelectorAll(query); setAllHeadings(result); - }, [depths, wrapper, router.asPath]); - - const [headingsTree, setHeadingsTree] = useState<Heading[]>([]); - - const getElementDepth = useCallback( - (el: HTMLHeadingElement) => { + }, [depths, wrapper]); + + const getDepth = useCallback( + /** + * Retrieve the heading element depth. + * + * @param {HTMLHeadingElement} el - An heading element. + * @returns {number} The heading depth. + */ + (el: HTMLHeadingElement): number => { return depths.findIndex((depth) => depth === el.localName); }, [depths] ); const formatHeadings = useCallback( + /** + * Convert a list of headings into an array of Heading objects. + * + * @param {NodeListOf<HTMLHeadingElement>} headings - A list of headings. + * @returns {Heading[]} An array of Heading objects. + */ (headings: NodeListOf<HTMLHeadingElement>): Heading[] => { const formattedHeadings: Heading[] = []; Array.from(headings).forEach((heading) => { const title: string = heading.textContent!; const id = slugify(title); - const depth = getElementDepth(heading); + const depth = getDepth(heading); const children: Heading[] = []; heading.id = id; @@ -49,10 +80,16 @@ const useHeadingsTree = (wrapper: string) => { return formattedHeadings; }, - [getElementDepth] + [getDepth] ); const buildSubTree = useCallback( + /** + * Build the heading subtree. + * + * @param {Heading} parent - The heading parent. + * @param {Heading} currentHeading - The current heading element. + */ (parent: Heading, currentHeading: Heading): void => { if (parent.depth === currentHeading.depth - 1) { parent.children.push(currentHeading); @@ -65,6 +102,12 @@ const useHeadingsTree = (wrapper: string) => { ); const buildTree = useCallback( + /** + * Build a heading tree. + * + * @param {Heading[]} headings - An array of Heading objects. + * @returns {Heading[]} The headings tree. + */ (headings: Heading[]): Heading[] => { const tree: Heading[] = []; @@ -82,7 +125,13 @@ const useHeadingsTree = (wrapper: string) => { [buildSubTree] ); - const getHeadingsList = useCallback( + const getHeadingsTree = useCallback( + /** + * Retrieve a headings tree from a list of headings element. + * + * @param {NodeListOf<HTMLHeadingElement>} headings - A headings list. + * @returns {Heading[]} The headings tree. + */ (headings: NodeListOf<HTMLHeadingElement>): Heading[] => { const formattedHeadings = formatHeadings(headings); @@ -93,10 +142,10 @@ const useHeadingsTree = (wrapper: string) => { useEffect(() => { if (allHeadings) { - const headingsList = getHeadingsList(allHeadings); + const headingsList = getHeadingsTree(allHeadings); setHeadingsTree(headingsList); } - }, [allHeadings, getHeadingsList]); + }, [allHeadings, getHeadingsTree]); return headingsTree; }; diff --git a/src/utils/hooks/use-input-autofocus.tsx b/src/utils/hooks/use-input-autofocus.tsx new file mode 100644 index 0000000..c7700e9 --- /dev/null +++ b/src/utils/hooks/use-input-autofocus.tsx @@ -0,0 +1,39 @@ +import { RefObject, useEffect } from 'react'; + +export type UseInputAutofocusProps = { + /** + * The focus condition. True give focus to the input. + */ + condition: boolean; + /** + * An optional delay. Default: 0. + */ + delay?: number; + /** + * A reference to the input element. + */ + ref: RefObject<HTMLInputElement>; +}; + +/** + * Set focus on an input with an optional delay. + */ +const useInputAutofocus = ({ + condition, + delay = 0, + ref, +}: UseInputAutofocusProps) => { + useEffect(() => { + const timer = setTimeout(() => { + if (ref.current && condition) { + ref.current.focus(); + } + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [condition, delay, ref]); +}; + +export default useInputAutofocus; diff --git a/src/utils/hooks/use-is-mounted.tsx b/src/utils/hooks/use-is-mounted.tsx new file mode 100644 index 0000000..ca79afb --- /dev/null +++ b/src/utils/hooks/use-is-mounted.tsx @@ -0,0 +1,19 @@ +import { RefObject, useEffect, useState } from 'react'; + +/** + * Check if an HTML element is mounted. + * + * @param {RefObject<HTMLElement>} ref - A React reference to an HTML element. + * @returns {boolean} True if the HTML element is mounted. + */ +const useIsMounted = (ref: RefObject<HTMLElement>) => { + const [isMounted, setIsMounted] = useState<boolean>(false); + + useEffect(() => { + if (ref.current) setIsMounted(true); + }, [ref]); + + return isMounted; +}; + +export default useIsMounted; diff --git a/src/utils/hooks/use-local-storage.tsx b/src/utils/hooks/use-local-storage.tsx new file mode 100644 index 0000000..da0292b --- /dev/null +++ b/src/utils/hooks/use-local-storage.tsx @@ -0,0 +1,35 @@ +import { LocalStorage } from '@services/local-storage'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +export type UseLocalStorageReturn<T> = { + value: T; + setValue: Dispatch<SetStateAction<T>>; +}; + +/** + * Use the local storage. + * + * @param {string} key - The storage local key. + * @param {T} [fallbackValue] - A fallback value if local storage is empty. + * @returns {UseLocalStorageReturn<T>} An object with value and setValue. + */ +const useLocalStorage = <T extends unknown>( + key: string, + fallbackValue: T +): UseLocalStorageReturn<T> => { + const getInitialValue = () => { + if (typeof window === 'undefined') return fallbackValue; + const storedValue = LocalStorage.get<T>(key); + return storedValue || fallbackValue; + }; + + const [value, setValue] = useState<T>(getInitialValue); + + useEffect(() => { + LocalStorage.set(key, value); + }, [key, value]); + + return { value, setValue }; +}; + +export default useLocalStorage; diff --git a/src/utils/hooks/use-pagination.tsx b/src/utils/hooks/use-pagination.tsx new file mode 100644 index 0000000..a80a539 --- /dev/null +++ b/src/utils/hooks/use-pagination.tsx @@ -0,0 +1,117 @@ +import { type EdgesResponse, type EdgesVars } from '@services/graphql/api'; +import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite'; + +export type UsePaginationProps<T> = { + /** + * The initial data. + */ + fallbackData: EdgesResponse<T>[]; + /** + * A function to fetch more data. + */ + fetcher: (props: EdgesVars) => Promise<EdgesResponse<T>>; + /** + * The number of results per page. + */ + perPage: number; + /** + * An optional search string. + */ + search?: string; +}; + +export type UsePaginationReturn<T> = { + /** + * The data from the API. + */ + data?: EdgesResponse<T>[]; + /** + * An error thrown by fetcher. + */ + error: any; + /** + * Determine if there's more data to fetch. + */ + hasNextPage?: boolean; + /** + * Determine if the initial data is loading. + */ + isLoadingInitialData: boolean; + /** + * Determine if more data is currently loading. + */ + isLoadingMore?: boolean; + /** + * Determine if the data is refreshing. + */ + isRefreshing?: boolean; + /** + * Determine if there's a request or revalidation loading. + */ + isValidating: boolean; + /** + * Set the number of pages that need to be fetched. + */ + setSize: ( + size: number | ((_size: number) => number) + ) => Promise<EdgesResponse<T>[] | undefined>; +}; + +/** + * Handle data fetching with pagination. + * + * This hook is a wrapper of `useSWRInfinite` hook. + * + * @param {UsePaginationProps} props - The pagination configuration. + * @returns {UsePaginationReturn} An object with pagination data and helpers. + */ +const usePagination = <T extends object>({ + fallbackData, + fetcher, + perPage, + search, +}: UsePaginationProps<T>): UsePaginationReturn<T> => { + const getKey: SWRInfiniteKeyLoader = ( + pageIndex: number, + previousData: EdgesResponse<T> + ): EdgesVars | null => { + // Reached the end. + if (previousData && !previousData.edges.length) return null; + + // Fetch data using this parameters. + return pageIndex === 0 + ? { first: perPage, search } + : { + first: perPage, + after: previousData.pageInfo.endCursor, + search, + }; + }; + + const { data, error, isValidating, size, setSize } = useSWRInfinite( + getKey, + fetcher, + { fallbackData } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + const isRefreshing = isValidating && data && data.length === size; + const hasNextPage = + data && data.length > 0 && data[data.length - 1].pageInfo.hasNextPage; + + return { + data, + error, + hasNextPage, + isLoadingInitialData, + isLoadingMore, + isRefreshing, + isValidating, + setSize, + }; +}; + +export default usePagination; diff --git a/src/utils/hooks/use-prism.tsx b/src/utils/hooks/use-prism.tsx new file mode 100644 index 0000000..ef1a4c8 --- /dev/null +++ b/src/utils/hooks/use-prism.tsx @@ -0,0 +1,182 @@ +import Prism from 'prismjs'; +import { useEffect, useMemo } from 'react'; +import { useIntl } from 'react-intl'; + +const PRISM_PLUGINS = [ + 'autoloader', + 'color-scheme', + 'command-line', + 'copy-to-clipboard', + 'diff-highlight', + 'inline-color', + 'line-highlight', + 'line-numbers', + 'match-braces', + 'normalize-whitespace', + 'show-language', + 'toolbar', +] as const; + +export type PrismPlugin = typeof PRISM_PLUGINS[number]; + +export type DefaultPrismPlugin = Extract< + PrismPlugin, + | 'autoloader' + | 'color-scheme' + | 'copy-to-clipboard' + | 'match-braces' + | 'normalize-whitespace' + | 'show-language' + | 'toolbar' +>; + +export type OptionalPrismPlugin = Exclude<PrismPlugin, DefaultPrismPlugin>; + +export type PrismLanguage = + | 'apacheconf' + | 'bash' + | 'css' + | 'diff' + | 'docker' + | 'editorconfig' + | 'ejs' + | 'git' + | 'graphql' + | 'html' + | 'ignore' + | 'ini' + | 'javascript' + | 'jsdoc' + | 'json' + | 'jsx' + | 'makefile' + | 'markup' + | 'php' + | 'phpdoc' + | 'regex' + | 'scss' + | 'shell-session' + | 'smarty' + | 'tcl' + | 'toml' + | 'tsx' + | 'twig' + | 'yaml'; + +export type PrismAttributes = { + 'data-prismjs-copy': string; + 'data-prismjs-copy-success': string; + 'data-prismjs-copy-error': string; + 'data-prismjs-color-scheme-dark': string; + 'data-prismjs-color-scheme-light': string; +}; + +export type UsePrismProps = { + language?: PrismLanguage; + plugins: OptionalPrismPlugin[]; +}; + +export type UsePrismReturn = { + attributes: PrismAttributes; + className: string; +}; + +/** + * Import and configure all given Prism plugins. + * + * @param {PrismPlugin[]} plugins - The Prism plugins to activate. + */ +const loadPrismPlugins = async (plugins: PrismPlugin[]) => { + for (const plugin of plugins) { + try { + if (plugin === 'color-scheme') { + await import(`@utils/plugins/prism-${plugin}`); + } else { + await import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`); + } + + if (plugin === 'autoloader') { + Prism.plugins.autoloader.languages_path = '/prism/'; + } + } catch (error) { + console.error('usePrism: an error occurred while loading Prism plugins.'); + console.error(error); + } + } +}; + +/** + * Use Prism and its plugins. + * + * @param {UsePrismProps} props - An object of options. + * @returns {UsePrismReturn} An object of data. + */ +const usePrism = ({ language, plugins }: UsePrismProps): UsePrismReturn => { + /** + * The order matter. Toolbar must be loaded before some other plugins. + */ + const defaultPlugins: DefaultPrismPlugin[] = useMemo( + () => [ + 'toolbar', + 'autoloader', + 'show-language', + 'copy-to-clipboard', + 'color-scheme', + 'match-braces', + 'normalize-whitespace', + ], + [] + ); + + useEffect(() => { + loadPrismPlugins([...defaultPlugins, ...plugins]).then(() => { + Prism.highlightAll(); + }); + }, [defaultPlugins, plugins]); + + const defaultClassName = 'match-braces'; + const languageClassName = language ? `language-${language}` : ''; + const pluginsClassName = plugins.join(' '); + const className = `${defaultClassName} ${pluginsClassName} ${languageClassName}`; + + const intl = useIntl(); + const copyText = intl.formatMessage({ + defaultMessage: 'Copy', + description: 'usePrism: copy button text (not clicked)', + id: '6GySNl', + }); + const copiedText = intl.formatMessage({ + defaultMessage: 'Copied!', + description: 'usePrism: copy button text (clicked)', + id: 'nsw6Th', + }); + const errorText = intl.formatMessage({ + defaultMessage: 'Use Ctrl+c to copy', + description: 'usePrism: copy button error text', + id: 'lKhTGM', + }); + const darkTheme = intl.formatMessage({ + defaultMessage: 'Dark Theme 🌙', + description: 'usePrism: toggle dark theme button text', + id: 'QLisK6', + }); + const lightTheme = intl.formatMessage({ + defaultMessage: 'Light Theme 🌞', + description: 'usePrism: toggle light theme button text', + id: 'hHVgW3', + }); + const attributes = { + 'data-prismjs-copy': copyText, + 'data-prismjs-copy-success': copiedText, + 'data-prismjs-copy-error': errorText, + 'data-prismjs-color-scheme-dark': darkTheme, + 'data-prismjs-color-scheme-light': lightTheme, + }; + + return { + attributes, + className, + }; +}; + +export default usePrism; diff --git a/src/utils/hooks/use-query-selector-all.tsx b/src/utils/hooks/use-query-selector-all.tsx new file mode 100644 index 0000000..6ac8a08 --- /dev/null +++ b/src/utils/hooks/use-query-selector-all.tsx @@ -0,0 +1,24 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; + +/** + * Use `document.querySelectorAll`. + * + * @param {string} query - A query. + * @returns {NodeListOf<HTMLElementTagNameMap[T]|undefined>} - The node list. + */ +const useQuerySelectorAll = <T extends keyof HTMLElementTagNameMap>( + query: string +): NodeListOf<HTMLElementTagNameMap[T]> | undefined => { + const [elements, setElements] = + useState<NodeListOf<HTMLElementTagNameMap[T]>>(); + const { asPath } = useRouter(); + + useEffect(() => { + setElements(document.querySelectorAll(query)); + }, [asPath, query]); + + return elements; +}; + +export default useQuerySelectorAll; diff --git a/src/utils/hooks/use-reading-time.tsx b/src/utils/hooks/use-reading-time.tsx new file mode 100644 index 0000000..fb54135 --- /dev/null +++ b/src/utils/hooks/use-reading-time.tsx @@ -0,0 +1,58 @@ +import { useIntl } from 'react-intl'; + +/** + * Retrieve the estimated reading time by words count. + * + * @param {number} wordsCount - The number of words. + * @returns {string} The estimated reading time. + */ +const useReadingTime = ( + wordsCount: number, + onlyMinutes: boolean = false +): string => { + const intl = useIntl(); + const wordsPerMinute = 245; + const wordsPerSecond = wordsPerMinute / 60; + const estimatedTimeInSeconds = wordsCount / wordsPerSecond; + + if (onlyMinutes) { + const estimatedTimeInMinutes = Math.round(estimatedTimeInSeconds / 60); + + return intl.formatMessage( + { + defaultMessage: '{minutesCount} minutes', + description: 'useReadingTime: rounded minutes count', + id: 's1i43J', + }, + { minutesCount: estimatedTimeInMinutes } + ); + } else { + const estimatedTimeInMinutes = Math.floor(estimatedTimeInSeconds / 60); + + if (estimatedTimeInMinutes <= 0) { + return intl.formatMessage( + { + defaultMessage: '{count} seconds', + description: 'useReadingTime: seconds count', + id: 'i7Wq3G', + }, + { count: estimatedTimeInSeconds.toFixed(0) } + ); + } + + const remainingSeconds = Math.round( + estimatedTimeInSeconds - estimatedTimeInMinutes * 60 + ).toFixed(0); + + return intl.formatMessage( + { + defaultMessage: '{minutesCount} minutes {secondsCount} seconds', + description: 'useReadingTime: minutes + seconds count', + id: 'OevMeU', + }, + { minutesCount: estimatedTimeInMinutes, secondsCount: remainingSeconds } + ); + } +}; + +export default useReadingTime; diff --git a/src/utils/hooks/use-redirection.tsx b/src/utils/hooks/use-redirection.tsx new file mode 100644 index 0000000..9eb26c2 --- /dev/null +++ b/src/utils/hooks/use-redirection.tsx @@ -0,0 +1,33 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +export type RouterQuery = { + param: string; + value: string; +}; + +export type UseRedirectionProps = { + /** + * The router query. + */ + query: RouterQuery; + /** + * The redirection url. + */ + redirectTo: string; +}; + +/** + * Redirect to another url when router query match the given parameters. + * + * @param {UseRedirectionProps} props - The redirection parameters. + */ +const useRedirection = ({ query, redirectTo }: UseRedirectionProps) => { + const router = useRouter(); + + useEffect(() => { + if (router.query[query.param] === query.value) router.push(redirectTo); + }, [query, redirectTo, router]); +}; + +export default useRedirection; diff --git a/src/utils/hooks/use-route-change.tsx b/src/utils/hooks/use-route-change.tsx new file mode 100644 index 0000000..82e01a1 --- /dev/null +++ b/src/utils/hooks/use-route-change.tsx @@ -0,0 +1,12 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +const useRouteChange = (callback: () => void) => { + const { events } = useRouter(); + + useEffect(() => { + events.on('routeChangeStart', callback); + }, [events, callback]); +}; + +export default useRouteChange; diff --git a/src/utils/hooks/use-scroll-position.tsx b/src/utils/hooks/use-scroll-position.tsx new file mode 100644 index 0000000..47cfdd0 --- /dev/null +++ b/src/utils/hooks/use-scroll-position.tsx @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; + +/** + * Execute the given function based on scroll position. + * + * @param scrollHandler - A callback function. + */ +const useScrollPosition = (scrollHandler: () => void) => { + useEffect(() => { + window.addEventListener('scroll', scrollHandler); + return () => window.removeEventListener('scroll', scrollHandler); + }, [scrollHandler]); +}; + +export default useScrollPosition; diff --git a/src/utils/hooks/use-settings.tsx b/src/utils/hooks/use-settings.tsx new file mode 100644 index 0000000..cc5261b --- /dev/null +++ b/src/utils/hooks/use-settings.tsx @@ -0,0 +1,118 @@ +import photo from '@assets/images/armand-philippot.jpg'; +import { settings } from '@utils/config'; +import { useRouter } from 'next/router'; + +export type BlogSettings = { + /** + * The number of posts per page. + */ + postsPerPage: number; +}; + +export type CopyrightSettings = { + /** + * The copyright end year. + */ + end: string; + /** + * The copyright start year. + */ + start: string; +}; + +export type LocaleSettings = { + /** + * The default locale. + */ + default: string; + /** + * The supported locales. + */ + supported: string[]; +}; + +export type PictureSettings = { + /** + * The picture height. + */ + height: number; + /** + * The picture url. + */ + src: string; + /** + * The picture width. + */ + width: number; +}; + +export type WebsiteSettings = { + /** + * The website name. + */ + name: string; + /** + * The website baseline. + */ + baseline: string; + /** + * The website copyright dates. + */ + copyright: CopyrightSettings; + /** + * The website admin email. + */ + email: string; + /** + * The website locales. + */ + locales: LocaleSettings; + /** + * A picture representing the website. + */ + picture: PictureSettings; + /** + * The website url. + */ + url: string; +}; + +export type UseSettingsReturn = { + blog: BlogSettings; + website: WebsiteSettings; +}; + +/** + * Retrieve the website and blog settings. + * + * @returns {UseSettingsReturn} - An object describing settings. + */ +const useSettings = (): UseSettingsReturn => { + const { baseline, copyright, email, locales, name, postsPerPage, url } = + settings; + const router = useRouter(); + const locale = router.locale || locales.defaultLocale; + + return { + blog: { + postsPerPage, + }, + website: { + baseline: locale.startsWith('en') ? baseline.en : baseline.fr, + copyright: { + end: copyright.endYear, + start: copyright.startYear, + }, + email, + locales: { + default: locales.defaultLocale, + supported: locales.supported, + }, + name, + picture: photo, + url, + }, + }; +}; + +export default useSettings; diff --git a/src/utils/hooks/use-styles.tsx b/src/utils/hooks/use-styles.tsx new file mode 100644 index 0000000..d47e9fb --- /dev/null +++ b/src/utils/hooks/use-styles.tsx @@ -0,0 +1,29 @@ +import { RefObject, useEffect } from 'react'; + +export type UseStylesProps = { + /** + * A property name or a CSS variable. + */ + property: string; + /** + * The styles. + */ + styles: string; + /** + * A targeted element reference. + */ + target: RefObject<HTMLElement>; +}; + +/** + * Add styles to an element using a React reference. + * + * @param {UseStylesProps} props - An object with property, styles and target. + */ +const useStyles = ({ property, styles, target }: UseStylesProps) => { + useEffect(() => { + if (target.current) target.current.style.setProperty(property, styles); + }, [property, styles, target]); +}; + +export default useStyles; diff --git a/src/utils/hooks/use-update-ackee-options.tsx b/src/utils/hooks/use-update-ackee-options.tsx new file mode 100644 index 0000000..7c1d98a --- /dev/null +++ b/src/utils/hooks/use-update-ackee-options.tsx @@ -0,0 +1,19 @@ +import { useAckeeTracker } from '@utils/providers/ackee'; +import { useEffect } from 'react'; + +export type AckeeOptions = 'full' | 'partial'; + +/** + * Update Ackee settings with the given choice. + * + * @param {AckeeOptions} value - Either `full` or `partial`. + */ +const useUpdateAckeeOptions = (value: AckeeOptions) => { + const { setDetailed } = useAckeeTracker(); + + useEffect(() => { + setDetailed(value === 'full'); + }, [value, setDetailed]); +}; + +export default useUpdateAckeeOptions; diff --git a/src/utils/providers/ackee.tsx b/src/utils/providers/ackee.tsx index c103668..0cb0166 100644 --- a/src/utils/providers/ackee.tsx +++ b/src/utils/providers/ackee.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'next/router'; -import { createContext, FC, useContext, useState } from 'react'; +import { createContext, FC, ReactNode, useContext, useState } from 'react'; import useAckee from 'use-ackee'; export type AckeeProps = { @@ -10,6 +10,7 @@ export type AckeeProps = { }; export type AckeeProviderProps = { + children: ReactNode; domain: string; siteId: string; ignoreLocalhost?: boolean; diff --git a/src/utils/providers/prism-theme.tsx b/src/utils/providers/prism-theme.tsx index 2ed8454..dd8feb7 100644 --- a/src/utils/providers/prism-theme.tsx +++ b/src/utils/providers/prism-theme.tsx @@ -1,7 +1,10 @@ -import { LocalStorage } from '@services/local-storage'; +import useAttributes from '@utils/hooks/use-attributes'; +import useLocalStorage from '@utils/hooks/use-local-storage'; +import useQuerySelectorAll from '@utils/hooks/use-query-selector-all'; import { createContext, FC, + ReactNode, useCallback, useContext, useEffect, @@ -9,7 +12,7 @@ import { } from 'react'; export type PrismTheme = 'dark' | 'light' | 'system'; -export type ResolvedPrismTheme = 'dark' | 'light'; +export type ResolvedPrismTheme = Exclude<PrismTheme, 'system'>; export type UsePrismThemeProps = { themes: PrismTheme[]; @@ -17,11 +20,11 @@ export type UsePrismThemeProps = { setTheme: (theme: PrismTheme) => void; resolvedTheme?: ResolvedPrismTheme; codeBlocks?: NodeListOf<HTMLPreElement>; - setCodeBlocks: (codeBlocks: NodeListOf<HTMLPreElement>) => void; }; export type PrismThemeProviderProps = { attribute?: string; + children: ReactNode; storageKey?: string; themes?: PrismTheme[]; }; @@ -31,14 +34,16 @@ export const PrismThemeContext = createContext<UsePrismThemeProps>({ setTheme: (_) => { // This is intentional. }, - setCodeBlocks: (_) => { - // This is intentional. - }, }); export const usePrismTheme = () => useContext(PrismThemeContext); -const prefersDarkScheme = () => { +/** + * Check if user prefers dark color scheme. + * + * @returns {boolean|undefined} True if `prefers-color-scheme` is set to `dark`. + */ +const prefersDarkScheme = (): boolean | undefined => { if (typeof window === 'undefined') return; return ( @@ -47,40 +52,35 @@ const prefersDarkScheme = () => { ); }; +/** + * Check if a given string is a Prism theme name. + * + * @param {string} theme - A string. + * @returns {boolean} True if the given string match a Prism theme name. + */ const isValidTheme = (theme: string): boolean => { return theme === 'dark' || theme === 'light' || theme === 'system'; }; -const getTheme = (key: string): PrismTheme | undefined => { - if (typeof window === 'undefined') return undefined; - const storageValue = LocalStorage.get(key); - - return storageValue && isValidTheme(storageValue) - ? (storageValue as PrismTheme) - : undefined; -}; - export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({ attribute = 'data-prismjs-color-scheme-current', storageKey = 'prismjs-color-scheme', themes = ['dark', 'light', 'system'], children, }) => { + /** + * Retrieve the theme to use depending on `prefers-color-scheme`. + */ const getThemeFromSystem = useCallback(() => { return prefersDarkScheme() ? 'dark' : 'light'; }, []); - const [prismTheme, setPrismTheme] = useState<PrismTheme>( - getTheme(storageKey) || 'system' - ); - - const updateTheme = (theme: PrismTheme) => { - setPrismTheme(theme); - }; + const { value: prismTheme, setValue: setPrismTheme } = + useLocalStorage<PrismTheme>(storageKey, 'system'); useEffect(() => { - LocalStorage.set(storageKey, prismTheme); - }, [prismTheme, storageKey]); + if (!isValidTheme(prismTheme)) setPrismTheme('system'); + }, [prismTheme, setPrismTheme]); const [resolvedTheme, setResolvedTheme] = useState<ResolvedPrismTheme>(); @@ -107,22 +107,12 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({ .removeEventListener('change', updateResolvedTheme); }, [updateResolvedTheme]); - const [preTags, setPreTags] = useState<NodeListOf<HTMLPreElement>>(); - - const updatePreTags = useCallback((tags: NodeListOf<HTMLPreElement>) => { - setPreTags(tags); - }, []); - - const updatePreTagsAttribute = useCallback(() => { - preTags?.forEach((pre) => { - pre.setAttribute(attribute, prismTheme); - }); - }, [attribute, preTags, prismTheme]); - - useEffect(() => { - updatePreTagsAttribute(); - }, [updatePreTagsAttribute, prismTheme]); + const preTags = useQuerySelectorAll<'pre'>('pre'); + useAttributes({ elements: preTags, attribute, value: prismTheme }); + /** + * Listen for changes on pre attributes and update theme. + */ const listenAttributeChange = useCallback( (pre: HTMLPreElement) => { var observer = new MutationObserver(function (mutations) { @@ -137,15 +127,12 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({ attributeFilter: [attribute], }); }, - [attribute] + [attribute, setPrismTheme] ); useEffect(() => { if (!preTags) return; - - preTags.forEach((pre) => { - listenAttributeChange(pre); - }); + preTags.forEach(listenAttributeChange); }, [preTags, listenAttributeChange]); return ( @@ -153,9 +140,8 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({ value={{ themes, theme: prismTheme, - setTheme: updateTheme, + setTheme: setPrismTheme, codeBlocks: preTags, - setCodeBlocks: updatePreTags, resolvedTheme, }} > |
