aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-30 19:30:43 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-01 16:08:54 +0100
commit5b762b1b669454a89899c4bdf6008027d9615acf (patch)
tree37087f4ee9d14ae131bde15a48d7d04e83ae6cbd /src/utils
parentf7e6f42216c3cbeab9add475a61bb407c6be3519 (diff)
refactor(pages): refine Article pages
* use rehype to update code blocks class names * fix widget heading level (after a level 1 it should always be a level 2 and not 3) * replace Spinner with LoadingPage and LoadingPageComments components to keep layout coherent * refactor useArticle and useComments hooks * fix URLs in JSON LD schema * add Cypress tests
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/helpers/rehype.ts89
-rw-r--r--src/utils/helpers/schema-org.ts49
-rw-r--r--src/utils/hooks/use-article.ts35
-rw-r--r--src/utils/hooks/use-article/index.ts1
-rw-r--r--src/utils/hooks/use-article/use-article.test.ts54
-rw-r--r--src/utils/hooks/use-article/use-article.ts28
-rw-r--r--src/utils/hooks/use-comments.ts32
-rw-r--r--src/utils/hooks/use-comments/index.ts1
-rw-r--r--src/utils/hooks/use-comments/use-comments.test.ts49
-rw-r--r--src/utils/hooks/use-comments/use-comments.ts42
10 files changed, 299 insertions, 81 deletions
diff --git a/src/utils/helpers/rehype.ts b/src/utils/helpers/rehype.ts
index fc51da1..f061fc2 100644
--- a/src/utils/helpers/rehype.ts
+++ b/src/utils/helpers/rehype.ts
@@ -1,3 +1,11 @@
+import type Hast from 'hast';
+import { classnames } from 'hast-util-classnames';
+import rehypeParse from 'rehype-parse';
+import rehypeSlug from 'rehype-slug';
+import rehypeStringify from 'rehype-stringify';
+import { unified, type Plugin as UnifiedPlugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
/**
* Update a stringified HTML tree using unified plugins.
*
@@ -6,16 +14,83 @@
* @param {string} content - The page contents.
* @returns {string} The updated page contents.
*/
-export const updateContentTree = async (content: string): Promise<string> => {
- const { unified } = await import('unified');
- const rehypeParse = (await import('rehype-parse')).default;
- const rehypeSlug = (await import('rehype-slug')).default;
- const rehypeStringify = (await import('rehype-stringify')).default;
-
- return unified()
+export const updateContentTree = (content: string): string =>
+ unified()
.use(rehypeParse, { fragment: true })
.use(rehypeSlug)
.use(rehypeStringify)
.processSync(content)
.toString();
+
+const isSubStrIn = (substr: string | RegExp, str: string) => {
+ if (typeof substr === 'string') return str.includes(substr);
+
+ return substr.test(str);
};
+
+const isNodeContainsClass = (
+ node: Hast.Element,
+ className: string | RegExp
+) => {
+ if (Array.isArray(node.properties.className)) {
+ return node.properties.className.some(
+ (singleClass) =>
+ typeof singleClass === 'string' && isSubStrIn(className, singleClass)
+ );
+ }
+
+ if (typeof node.properties.className === 'string')
+ return isSubStrIn(className, node.properties.className);
+
+ return false;
+};
+
+const rehypePrismClass: UnifiedPlugin<
+ Record<'className', string>[],
+ Hast.Root
+> =
+ ({ className }) =>
+ (tree) => {
+ const wpBlockClassName = 'wp-block-code';
+ const lineNumbersClassName = className
+ .replace('command-line', '')
+ .replace(/\s\s+/g, ' ');
+ const commandLineClassName = className
+ .replace('line-numbers', '')
+ .replace(/\s\s+/g, ' ');
+
+ visit(tree, 'element', (node) => {
+ if (
+ node.tagName === 'pre' &&
+ isNodeContainsClass(node, wpBlockClassName)
+ ) {
+ if (isNodeContainsClass(node, 'language-bash')) {
+ classnames(node, commandLineClassName);
+ node.properties['data-filter-output'] = '#output#';
+ } else if (isNodeContainsClass(node, /language-/)) {
+ classnames(node, lineNumbersClassName);
+ }
+ }
+ });
+ };
+
+/**
+ * Update a stringified HTML tree using unified plugins.
+ *
+ * It will parse the provided content to update the classnames of WordPress
+ * code blocks.
+ *
+ * @param {string} content - The page contents.
+ * @param {string} className - The prism classNames.
+ * @returns {string} The updated page contents.
+ */
+export const updateWordPressCodeBlocks = (
+ content: string,
+ className: string
+): string =>
+ unified()
+ .use(rehypeParse, { fragment: true })
+ .use(rehypePrismClass, { className })
+ .use(rehypeStringify)
+ .processSync(content)
+ .toString();
diff --git a/src/utils/helpers/schema-org.ts b/src/utils/helpers/schema-org.ts
index f028f5a..633c35a 100644
--- a/src/utils/helpers/schema-org.ts
+++ b/src/utils/helpers/schema-org.ts
@@ -3,11 +3,12 @@ import type {
Article,
Blog,
BlogPosting,
+ Comment as CommentSchema,
ContactPage,
Graph,
WebPage,
} from 'schema-dts';
-import type { Dates } from '../../types';
+import type { Dates, SingleComment } from '../../types';
import { CONFIG } from '../config';
import { ROUTES } from '../constants';
import { trimTrailingChars } from './strings';
@@ -50,14 +51,48 @@ export const getBlogSchema = ({
inLanguage: locale,
isPartOf: isSinglePage
? {
- '@id': `${host}${slug}`,
+ '@id': `${host}/${slug}`,
}
: undefined,
license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
- mainEntityOfPage: isSinglePage ? undefined : { '@id': `${host}${slug}` },
+ mainEntityOfPage: isSinglePage ? undefined : { '@id': `${host}/${slug}` },
};
};
+/**
+ * Retrieve the JSON for Comment schema.
+ *
+ * @param props - The comments.
+ * @returns {CommentSchema[]} The JSON for Comment schema.
+ */
+export const getCommentsSchema = (comments: SingleComment[]): CommentSchema[] =>
+ comments.map((comment) => {
+ return {
+ '@context': 'https://schema.org',
+ '@id': `${CONFIG.url}/#comment-${comment.id}`,
+ '@type': 'Comment',
+ parentItem: comment.parentId
+ ? { '@id': `${CONFIG.url}/#comment-${comment.parentId}` }
+ : undefined,
+ about: { '@type': 'Article', '@id': `${CONFIG.url}/#article` },
+ author: {
+ '@type': 'Person',
+ name: comment.meta.author.name,
+ image: comment.meta.author.avatar?.src,
+ url: comment.meta.author.website,
+ },
+ creator: {
+ '@type': 'Person',
+ name: comment.meta.author.name,
+ image: comment.meta.author.avatar?.src,
+ url: comment.meta.author.website,
+ },
+ dateCreated: comment.meta.date,
+ datePublished: comment.meta.date,
+ text: comment.content,
+ };
+ });
+
export type SinglePageSchemaReturn = {
about: AboutPage;
contact: ContactPage;
@@ -159,10 +194,10 @@ export const getSinglePageSchema = <T extends SinglePageSchemaKind>({
isPartOf:
kind === 'post'
? {
- '@id': `${host}${ROUTES.BLOG}`,
+ '@id': `${host}/${ROUTES.BLOG}`,
}
: undefined,
- mainEntityOfPage: { '@id': `${host}${slug}` },
+ mainEntityOfPage: { '@id': `${host}/${slug}` },
} as SinglePageSchemaReturn[T];
};
@@ -203,7 +238,7 @@ export const getWebPageSchema = ({
updateDate,
}: GetWebPageSchemaProps): WebPage => {
return {
- '@id': `${host}${slug}`,
+ '@id': `${host}/${slug}`,
'@type': 'WebPage',
breadcrumb: { '@id': `${host}/#breadcrumb` },
lastReviewed: updateDate,
@@ -211,7 +246,7 @@ export const getWebPageSchema = ({
description,
inLanguage: locale,
reviewedBy: { '@id': `${host}/#branding` },
- url: `${host}${slug}`,
+ url: `${host}/${slug}`,
isPartOf: {
'@id': `${host}`,
},
diff --git a/src/utils/hooks/use-article.ts b/src/utils/hooks/use-article.ts
deleted file mode 100644
index 5e54ee4..0000000
--- a/src/utils/hooks/use-article.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useEffect, useState } from 'react';
-import useSWR from 'swr';
-import { convertPostToArticle, fetchPost } from '../../services/graphql';
-import type { Article, Maybe } from '../../types';
-
-export type UseArticleConfig = {
- /**
- * A fallback article
- */
- fallback?: Article;
- /**
- * The article slug
- */
- slug?: string;
-};
-
-/**
- * Retrieve an article by slug.
- *
- * @param {UseArticleConfig} config - The config.
- * @returns {Article|undefined} The matching article if it exists.
- */
-export const useArticle = ({
- slug,
- fallback,
-}: UseArticleConfig): Article | undefined => {
- const { data } = useSWR(slug, fetchPost, {});
- const [article, setArticle] = useState<Maybe<Article>>(fallback);
-
- useEffect(() => {
- if (data) convertPostToArticle(data).then((post) => setArticle(post));
- }, [data]);
-
- return article;
-};
diff --git a/src/utils/hooks/use-article/index.ts b/src/utils/hooks/use-article/index.ts
new file mode 100644
index 0000000..459fc6d
--- /dev/null
+++ b/src/utils/hooks/use-article/index.ts
@@ -0,0 +1 @@
+export * from './use-article';
diff --git a/src/utils/hooks/use-article/use-article.test.ts b/src/utils/hooks/use-article/use-article.test.ts
new file mode 100644
index 0000000..7c9574c
--- /dev/null
+++ b/src/utils/hooks/use-article/use-article.test.ts
@@ -0,0 +1,54 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
+import { renderHook, waitFor } from '@testing-library/react';
+import { wpPostsFixture } from '../../../../tests/fixtures';
+import { ROUTES } from '../../constants';
+import { useArticle } from './use-article';
+
+describe('useArticle', () => {
+ beforeEach(() => {
+ /* Not sure why it is needed, but without it Jest was complaining with
+ * `Jest worker encountered 4 child process exceptions`... Maybe because of
+ * useSWR? */
+ jest.useFakeTimers({
+ doNotFake: ['queueMicrotask'],
+ });
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ /* eslint-disable max-statements */
+ it('fetch the requested article', async () => {
+ const { result } = renderHook(() => useArticle(wpPostsFixture[0].slug));
+
+ // Inaccurate assertions count because of waitFor...
+ //expect.assertions(8);
+ expect.hasAssertions();
+
+ expect(result.current.article).toBeUndefined();
+ expect(result.current.isError).toBe(false);
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isValidating).toBe(true);
+
+ jest.advanceTimersToNextTimer();
+
+ await waitFor(() =>
+ expect(result.current.article?.slug).toBe(
+ `${ROUTES.ARTICLE}/${wpPostsFixture[0].slug}`
+ )
+ );
+ expect(result.current.isError).toBe(false);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isValidating).toBe(false);
+ });
+ /* eslint-enable max-statements */
+});
diff --git a/src/utils/hooks/use-article/use-article.ts b/src/utils/hooks/use-article/use-article.ts
new file mode 100644
index 0000000..fbbc6bd
--- /dev/null
+++ b/src/utils/hooks/use-article/use-article.ts
@@ -0,0 +1,28 @@
+import useSWR from 'swr';
+import { convertPostToArticle, fetchPost } from '../../../services/graphql';
+import type { Article, Maybe, WPPost } from '../../../types';
+
+export type UseArticleReturn<T extends Maybe<WPPost>> = {
+ article: T extends undefined ? Maybe<Article> : Article;
+ isError: boolean;
+ isLoading: boolean;
+ isValidating: boolean;
+};
+
+export const useArticle = <T extends Maybe<WPPost>>(
+ slug: string,
+ fallback?: T
+): UseArticleReturn<T> => {
+ const { data, error, isLoading, isValidating } = useSWR(slug, fetchPost, {
+ fallbackData: fallback,
+ });
+
+ if (error) console.error(error);
+
+ return {
+ article: data ? convertPostToArticle(data) : undefined,
+ isError: !!error,
+ isLoading,
+ isValidating,
+ } as UseArticleReturn<T>;
+};
diff --git a/src/utils/hooks/use-comments.ts b/src/utils/hooks/use-comments.ts
deleted file mode 100644
index 94a2d7e..0000000
--- a/src/utils/hooks/use-comments.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import useSWR from 'swr';
-import {
- type FetchCommentsListInput,
- fetchCommentsList,
- convertWPCommentToComment,
- buildCommentsTree,
-} from '../../services/graphql';
-import type { SingleComment } from '../../types';
-
-export type UseCommentsConfig = FetchCommentsListInput & {
- fallback?: SingleComment[];
-};
-
-/**
- * Retrieve the comments of a page/article.
- *
- * @param {string | number} contentId - A page/article id.
- * @returns {SingleComment[]|undefined}
- */
-export const useComments = ({
- fallback,
- ...input
-}: UseCommentsConfig): SingleComment[] | undefined => {
- const { data } = useSWR(input, fetchCommentsList, {});
-
- if (!data) return fallback;
-
- const comments = data.map(convertWPCommentToComment);
- const commentsTree = buildCommentsTree(comments);
-
- return commentsTree;
-};
diff --git a/src/utils/hooks/use-comments/index.ts b/src/utils/hooks/use-comments/index.ts
new file mode 100644
index 0000000..8f69ffd
--- /dev/null
+++ b/src/utils/hooks/use-comments/index.ts
@@ -0,0 +1 @@
+export * from './use-comments';
diff --git a/src/utils/hooks/use-comments/use-comments.test.ts b/src/utils/hooks/use-comments/use-comments.test.ts
new file mode 100644
index 0000000..f05f9eb
--- /dev/null
+++ b/src/utils/hooks/use-comments/use-comments.test.ts
@@ -0,0 +1,49 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useComments } from './use-comments';
+
+describe('useComments', () => {
+ beforeEach(() => {
+ /* Not sure why it is needed, but without it Jest was complaining with
+ * `Jest worker encountered 4 child process exceptions`... Maybe because of
+ * useSWR? */
+ jest.useFakeTimers({
+ doNotFake: ['queueMicrotask'],
+ });
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ /* eslint-disable max-statements */
+ it('fetch the requested comments', async () => {
+ const { result } = renderHook(() => useComments({}));
+
+ // Inaccurate assertions count because of waitFor...
+ //expect.assertions(8);
+ expect.hasAssertions();
+
+ expect(result.current.comments).toBeUndefined();
+ expect(result.current.isError).toBe(false);
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isValidating).toBe(true);
+
+ jest.advanceTimersToNextTimer();
+
+ await waitFor(() => expect(result.current.comments).toBeDefined());
+
+ expect(result.current.isError).toBe(false);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isValidating).toBe(false);
+ });
+ /* eslint-enable max-statements */
+});
diff --git a/src/utils/hooks/use-comments/use-comments.ts b/src/utils/hooks/use-comments/use-comments.ts
new file mode 100644
index 0000000..cb967a5
--- /dev/null
+++ b/src/utils/hooks/use-comments/use-comments.ts
@@ -0,0 +1,42 @@
+import useSWR from 'swr';
+import {
+ type FetchCommentsListInput,
+ buildCommentsTree,
+ convertWPCommentToComment,
+ fetchCommentsList,
+} from '../../../services/graphql';
+import type { Maybe, SingleComment, WPComment } from '../../../types';
+
+export type UseCommentsReturn<T extends Maybe<WPComment[]>> = {
+ comments: T extends undefined ? Maybe<SingleComment[]> : SingleComment[];
+ isError: boolean;
+ isLoading: boolean;
+ isValidating: boolean;
+};
+
+export type UseCommentsConfig<T extends Maybe<WPComment[]>> =
+ FetchCommentsListInput & {
+ fallback?: T;
+ };
+
+export const useComments = <T extends Maybe<WPComment[]>>({
+ fallback,
+ ...input
+}: UseCommentsConfig<T>): UseCommentsReturn<T> => {
+ const { data, error, isLoading, isValidating } = useSWR(
+ input,
+ fetchCommentsList,
+ { fallbackData: fallback }
+ );
+
+ if (error) console.error(error);
+
+ return {
+ comments: data
+ ? buildCommentsTree(data.map(convertWPCommentToComment))
+ : undefined,
+ isError: !!error,
+ isLoading,
+ isValidating,
+ } as UseCommentsReturn<T>;
+};