summaryrefslogtreecommitdiffstats
path: root/src/utils
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-24 19:35:12 +0200
committerGitHub <noreply@github.com>2022-05-24 19:35:12 +0200
commitc85ab5ad43ccf52881ee224672c41ec30021cf48 (patch)
tree8058808d9bfca19383f120c46b34d99ff2f89f63 /src/utils
parent52404177c07a2aab7fc894362fb3060dff2431a0 (diff)
parent11b9de44a4b2f305a6a484187805e429b2767118 (diff)
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/config.ts3
-rw-r--r--src/utils/helpers/author.ts32
-rw-r--r--src/utils/helpers/dates.ts40
-rw-r--r--src/utils/helpers/format.ts310
-rw-r--r--src/utils/helpers/i18n.ts2
-rw-r--r--src/utils/helpers/images.ts18
-rw-r--r--src/utils/helpers/pages.ts85
-rw-r--r--src/utils/helpers/prism.ts34
-rw-r--r--src/utils/helpers/projects.ts109
-rw-r--r--src/utils/helpers/rss.ts51
-rw-r--r--src/utils/helpers/schema-org.ts224
-rw-r--r--src/utils/helpers/slugify.ts18
-rw-r--r--src/utils/helpers/sort.ts21
-rw-r--r--src/utils/helpers/strings.ts39
-rw-r--r--src/utils/hooks/use-add-classname.tsx34
-rw-r--r--src/utils/hooks/use-attributes.tsx52
-rw-r--r--src/utils/hooks/use-breadcrumb.tsx107
-rw-r--r--src/utils/hooks/use-click-outside.tsx46
-rw-r--r--src/utils/hooks/use-data-from-api.tsx23
-rw-r--r--src/utils/hooks/use-github-api.tsx (renamed from src/utils/hooks/useGithubApi.tsx)15
-rw-r--r--src/utils/hooks/use-headings-tree.tsx (renamed from src/utils/hooks/useHeadingsTree.tsx)89
-rw-r--r--src/utils/hooks/use-input-autofocus.tsx39
-rw-r--r--src/utils/hooks/use-is-mounted.tsx19
-rw-r--r--src/utils/hooks/use-local-storage.tsx35
-rw-r--r--src/utils/hooks/use-pagination.tsx117
-rw-r--r--src/utils/hooks/use-prism.tsx182
-rw-r--r--src/utils/hooks/use-query-selector-all.tsx24
-rw-r--r--src/utils/hooks/use-reading-time.tsx58
-rw-r--r--src/utils/hooks/use-redirection.tsx33
-rw-r--r--src/utils/hooks/use-route-change.tsx12
-rw-r--r--src/utils/hooks/use-scroll-position.tsx15
-rw-r--r--src/utils/hooks/use-settings.tsx118
-rw-r--r--src/utils/hooks/use-styles.tsx29
-rw-r--r--src/utils/hooks/use-update-ackee-options.tsx19
-rw-r--r--src/utils/providers/ackee.tsx3
-rw-r--r--src/utils/providers/prism-theme.tsx80
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,
}}
>