aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--jest.setup.js1
-rw-r--r--package.json4
-rw-r--r--src/components/atoms/links/sharing-link/sharing-link.module.scss2
-rw-r--r--src/components/organisms/comment/approved-comment/approved-comment.test.tsx4
-rw-r--r--src/components/organisms/comment/approved-comment/approved-comment.tsx5
-rw-r--r--src/components/templates/page/index.ts2
-rw-r--r--src/components/templates/page/loading-page-comments.stories.tsx22
-rw-r--r--src/components/templates/page/loading-page-comments.test.tsx13
-rw-r--r--src/components/templates/page/loading-page-comments.tsx34
-rw-r--r--src/components/templates/page/loading-page.stories.tsx22
-rw-r--r--src/components/templates/page/loading-page.test.tsx13
-rw-r--r--src/components/templates/page/loading-page.tsx28
-rw-r--r--src/components/templates/page/page.module.scss20
-rw-r--r--src/i18n/en.json20
-rw-r--r--src/i18n/fr.json20
-rw-r--r--src/pages/article/[slug].tsx227
-rw-r--r--src/pages/thematique/[slug].tsx4
-rw-r--r--src/services/graphql/helpers/convert-post-to-article.test.ts22
-rw-r--r--src/services/graphql/helpers/convert-post-to-article.ts9
-rw-r--r--src/styles/pages/article.module.scss74
-rw-r--r--src/styles/pages/blog.module.scss27
-rw-r--r--src/styles/pages/partials/_article-headings.scss6
-rw-r--r--src/styles/pages/partials/_article-wp-blocks.scss12
-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
-rw-r--r--tests/cypress/e2e/pages/article.cy.ts49
-rw-r--r--yarn.lock10
35 files changed, 692 insertions, 338 deletions
diff --git a/jest.setup.js b/jest.setup.js
index 718f274..e3bb6f2 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -14,6 +14,7 @@ jest.mock('src/utils/helpers/rehype.ts', () => {
return {
__esModule: true,
updateContentTree: jest.fn((str) => str),
+ updateWordPressCodeBlocks: jest.fn((str) => str),
};
});
diff --git a/package.json b/package.json
index cc0e479..3ef8bf4 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"@next/mdx": "^14.0.2",
"feed": "^4.2.2",
"graphql": "^16.8.1",
+ "hast-util-classnames": "^3.0.0",
"modern-normalize": "^2.0.0",
"next": "^14.0.2",
"next-sitemap": "^4.2.3",
@@ -64,6 +65,7 @@
"sharp": "^0.32.6",
"swr": "^2.2.4",
"unified": "^11.0.4",
+ "unist-util-visit": "^5.0.0",
"use-ackee": "^3.0.1"
},
"devDependencies": {
@@ -87,6 +89,7 @@
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.1.0",
"@testing-library/user-event": "^14.5.1",
+ "@types/hast": "^3.0.3",
"@types/jest": "^29.5.8",
"@types/mdx": "^2.0.10",
"@types/node": "^20.9.0",
@@ -124,7 +127,6 @@
"stylelint-config-standard-scss": "^11.1.0",
"typescript": "^5.2.2",
"undici": "^5.28.1",
- "unist-util-visit": "^5.0.0",
"webpack": "^5.89.0"
}
}
diff --git a/src/components/atoms/links/sharing-link/sharing-link.module.scss b/src/components/atoms/links/sharing-link/sharing-link.module.scss
index e1c9c3c..105c37f 100644
--- a/src/components/atoms/links/sharing-link/sharing-link.module.scss
+++ b/src/components/atoms/links/sharing-link/sharing-link.module.scss
@@ -6,7 +6,7 @@
padding: var(--spacing-2xs) var(--spacing-xs);
border-radius: fun.convert-px(3);
box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 var(--shadowColor);
- transition: all 0.3s linear 0s;
+ transition: all 0.2s linear 0s;
&:hover,
&:focus {
diff --git a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx
index 2e29b5f..b244a63 100644
--- a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx
+++ b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx
@@ -52,7 +52,9 @@ describe('ApprovedComment', () => {
/>
);
- expect(rtlScreen.getByRole('img')).toHaveAccessibleName(author.avatar.alt);
+ expect(rtlScreen.getByRole('figure')).toHaveAccessibleName(
+ author.avatar.alt
+ );
});
it('can render a link to the author website', () => {
diff --git a/src/components/organisms/comment/approved-comment/approved-comment.tsx b/src/components/organisms/comment/approved-comment/approved-comment.tsx
index 233146d..d834ba3 100644
--- a/src/components/organisms/comment/approved-comment/approved-comment.tsx
+++ b/src/components/organisms/comment/approved-comment/approved-comment.tsx
@@ -117,9 +117,10 @@ const ApprovedCommentWithRef: ForwardRefRenderFunction<
className={commentClass}
cover={
author.avatar ? (
- <CardCover hasBorders>
+ <CardCover aria-label={author.avatar.alt} hasBorders>
<NextImage
- alt={author.avatar.alt}
+ // eslint-disable-next-line react/jsx-no-literals
+ alt=""
height={96}
src={author.avatar.src}
width={96}
diff --git a/src/components/templates/page/index.ts b/src/components/templates/page/index.ts
index f6d2d48..f5330a7 100644
--- a/src/components/templates/page/index.ts
+++ b/src/components/templates/page/index.ts
@@ -1,3 +1,5 @@
+export * from './loading-page';
+export * from './loading-page-comments';
export * from './page';
export * from './page-body';
export * from './page-comments';
diff --git a/src/components/templates/page/loading-page-comments.stories.tsx b/src/components/templates/page/loading-page-comments.stories.tsx
new file mode 100644
index 0000000..6069068
--- /dev/null
+++ b/src/components/templates/page/loading-page-comments.stories.tsx
@@ -0,0 +1,22 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LoadingPageComments } from './loading-page-comments';
+
+/**
+ * LoadingPageComments - Storybook Meta
+ */
+export default {
+ title: 'Templates/LoadingPageComments',
+ component: LoadingPageComments,
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof LoadingPageComments>;
+
+const Template: ComponentStory<typeof LoadingPageComments> = (args) => (
+ <LoadingPageComments {...args} />
+);
+
+/**
+ * LoadingPageComments Stories - Example
+ */
+export const Example = Template.bind({});
diff --git a/src/components/templates/page/loading-page-comments.test.tsx b/src/components/templates/page/loading-page-comments.test.tsx
new file mode 100644
index 0000000..b9ccb3e
--- /dev/null
+++ b/src/components/templates/page/loading-page-comments.test.tsx
@@ -0,0 +1,13 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import { LoadingPageComments } from './loading-page-comments';
+
+describe('LoadingPageComments', () => {
+ it('renders a spinner', () => {
+ render(<LoadingPageComments />);
+
+ expect(
+ rtlScreen.getByText('The comments are loading...')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/templates/page/loading-page-comments.tsx b/src/components/templates/page/loading-page-comments.tsx
new file mode 100644
index 0000000..9235dcb
--- /dev/null
+++ b/src/components/templates/page/loading-page-comments.tsx
@@ -0,0 +1,34 @@
+import {
+ forwardRef,
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+} from 'react';
+import { useIntl } from 'react-intl';
+import { Spinner } from '../../atoms';
+import styles from './page.module.scss';
+
+export type LoadingPageCommentsProps = Omit<
+ HTMLAttributes<HTMLDivElement>,
+ 'children'
+>;
+
+const LoadingPageCommentsWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ LoadingPageCommentsProps
+> = ({ className = '', ...props }, ref) => {
+ const wrapperClass = `${styles.comments} ${className}`;
+ const intl = useIntl();
+ const loadingMsg = intl.formatMessage({
+ defaultMessage: 'The comments are loading...',
+ description: 'LoadingPageComments: loading message',
+ id: 'gYbxP4',
+ });
+
+ return (
+ <div {...props} className={wrapperClass} ref={ref}>
+ <Spinner className={styles.spinner}>{loadingMsg}</Spinner>
+ </div>
+ );
+};
+
+export const LoadingPageComments = forwardRef(LoadingPageCommentsWithRef);
diff --git a/src/components/templates/page/loading-page.stories.tsx b/src/components/templates/page/loading-page.stories.tsx
new file mode 100644
index 0000000..2ea0b33
--- /dev/null
+++ b/src/components/templates/page/loading-page.stories.tsx
@@ -0,0 +1,22 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LoadingPage } from './loading-page';
+
+/**
+ * LoadingPage - Storybook Meta
+ */
+export default {
+ title: 'Templates/LoadingPage',
+ component: LoadingPage,
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof LoadingPage>;
+
+const Template: ComponentStory<typeof LoadingPage> = (args) => (
+ <LoadingPage {...args} />
+);
+
+/**
+ * LoadingPage Stories - Example
+ */
+export const Example = Template.bind({});
diff --git a/src/components/templates/page/loading-page.test.tsx b/src/components/templates/page/loading-page.test.tsx
new file mode 100644
index 0000000..5163943
--- /dev/null
+++ b/src/components/templates/page/loading-page.test.tsx
@@ -0,0 +1,13 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import { LoadingPage } from './loading-page';
+
+describe('LoadingPage', () => {
+ it('renders a spinner', () => {
+ render(<LoadingPage />);
+
+ expect(
+ rtlScreen.getByText('The requested page is loading...')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/templates/page/loading-page.tsx b/src/components/templates/page/loading-page.tsx
new file mode 100644
index 0000000..18ceed0
--- /dev/null
+++ b/src/components/templates/page/loading-page.tsx
@@ -0,0 +1,28 @@
+import { forwardRef, type ForwardRefRenderFunction } from 'react';
+import { useIntl } from 'react-intl';
+import { Spinner } from '../../atoms';
+import { Page, type PageProps } from './page';
+import { PageBody } from './page-body';
+import styles from './page.module.scss';
+
+const LoadingPageWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ Omit<PageProps, 'children'>
+> = (props, ref) => {
+ const intl = useIntl();
+ const loadingMsg = intl.formatMessage({
+ defaultMessage: 'The requested page is loading...',
+ description: 'LoadingPage: loading message',
+ id: '0UzObH',
+ });
+
+ return (
+ <Page {...props} ref={ref}>
+ <PageBody>
+ <Spinner className={styles.spinner}>{loadingMsg}</Spinner>
+ </PageBody>
+ </Page>
+ );
+};
+
+export const LoadingPage = forwardRef(LoadingPageWithRef);
diff --git a/src/components/templates/page/page.module.scss b/src/components/templates/page/page.module.scss
index d2752a1..e7d3587 100644
--- a/src/components/templates/page/page.module.scss
+++ b/src/components/templates/page/page.module.scss
@@ -18,9 +18,6 @@
.section {
--border-size: #{fun.convert-px(3)};
--col-gap: clamp(var(--spacing-md), 4vw, var(--spacing-2xl));
- --left-col: 0;
- --right-col: 0;
- --main-col: minmax(0, 80ch);
@extend %grid;
@@ -30,6 +27,10 @@
.breadcrumbs,
.page--regular {
+ --left-col: 0;
+ --right-col: 0;
+ --main-col: minmax(0, 1fr);
+
margin-top: var(--spacing-sm);
}
@@ -74,7 +75,6 @@
.body {
grid-column: 2;
- margin-top: var(--spacing-sm);
padding-bottom: var(--spacing-md);
}
@@ -113,8 +113,9 @@
}
.section {
- --right-col: minmax(0, 1fr);
+ --main-col: minmax(0, 80ch);
--left-col: minmax(0, 1fr);
+ --right-col: minmax(0, 1fr);
@extend %grid;
@@ -202,6 +203,10 @@
}
}
+.spinner {
+ margin: var(--spacing-lg) auto 0;
+}
+
:where(.comments) {
.heading {
width: fit-content;
@@ -212,11 +217,16 @@
max-width: 40ch;
margin-inline: auto;
}
+
+ .spinner {
+ grid-column: 2;
+ }
}
@container page (width > #{var.get-breakpoint("md")}) {
.breadcrumbs,
.page--regular {
+ --main-col: minmax(0, 80ch);
--right-col: minmax(25ch, 1fr);
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index be67b38..671e2b1 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -39,6 +39,10 @@
"defaultMessage": "Written by:",
"description": "PageHeader: author meta label"
},
+ "0UzObH": {
+ "defaultMessage": "The requested page is loading...",
+ "description": "LoadingPage: loading message"
+ },
"0f7fty": {
"defaultMessage": "Share on Diaspora",
"description": "SharingWidget: Diaspora sharing link"
@@ -103,10 +107,6 @@
"defaultMessage": "Page not found.",
"description": "404Page: SEO - Meta description"
},
- "4iYISO": {
- "defaultMessage": "Loading the requested article...",
- "description": "ArticlePage: loading article message"
- },
"5C+1PP": {
"defaultMessage": "Blog",
"description": "SiteNavbar: main nav - blog link"
@@ -399,6 +399,10 @@
"defaultMessage": "It has been approved.",
"description": "PageComments: comment approved."
},
+ "VTJE8h": {
+ "defaultMessage": "{author}'s avatar",
+ "description": "Article: accessible name for the comment avatar"
+ },
"VkAnvv": {
"defaultMessage": "Send",
"description": "ContactForm: send button"
@@ -523,6 +527,10 @@
"defaultMessage": "Code blocks:",
"description": "PrismThemeToggle: theme label"
},
+ "gYbxP4": {
+ "defaultMessage": "The comments are loading...",
+ "description": "LoadingPageComments: loading message"
+ },
"hGvQpI": {
"defaultMessage": "Load more posts?",
"description": "PostsList: load more button"
@@ -619,6 +627,10 @@
"defaultMessage": "Discover search results for {query} on {websiteName}.",
"description": "SearchPage: SEO - Meta description"
},
+ "s57FTB": {
+ "defaultMessage": "Share",
+ "description": "Article: sharing widget title"
+ },
"s8/tyz": {
"defaultMessage": "Object:",
"description": "ContactForm: object label"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 0226f1e..c8b4058 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -39,6 +39,10 @@
"defaultMessage": "Écrit par :",
"description": "PageHeader: author meta label"
},
+ "0UzObH": {
+ "defaultMessage": "La page est en cours de chargement…",
+ "description": "LoadingPage: loading message"
+ },
"0f7fty": {
"defaultMessage": "Partager sur Diaspora",
"description": "SharingWidget: Diaspora sharing link"
@@ -103,10 +107,6 @@
"defaultMessage": "Page non trouvée.",
"description": "404Page: SEO - Meta description"
},
- "4iYISO": {
- "defaultMessage": "Chargement de l’article demandé…",
- "description": "ArticlePage: loading article message"
- },
"5C+1PP": {
"defaultMessage": "Blog",
"description": "SiteNavbar: main nav - blog link"
@@ -399,6 +399,10 @@
"defaultMessage": "Il a été approuvé.",
"description": "PageComments: comment approved."
},
+ "VTJE8h": {
+ "defaultMessage": "Avatar de {author}",
+ "description": "Article: accessible name for the comment avatar"
+ },
"VkAnvv": {
"defaultMessage": "Envoyer",
"description": "ContactForm: send button"
@@ -523,6 +527,10 @@
"defaultMessage": "Blocs de code :",
"description": "PrismThemeToggle: theme label"
},
+ "gYbxP4": {
+ "defaultMessage": "Les commentaires sont en cours de chargement…",
+ "description": "LoadingPageComments: loading message"
+ },
"hGvQpI": {
"defaultMessage": "Charger plus d’articles ?",
"description": "PostsList: load more button"
@@ -619,6 +627,10 @@
"defaultMessage": "Découvrez les résultats de recherche pour {query} sur {websiteName}.",
"description": "SearchPage: SEO - Meta description"
},
+ "s57FTB": {
+ "defaultMessage": "Partager",
+ "description": "Article: sharing widget title"
+ },
"s8/tyz": {
"defaultMessage": "Sujet :",
"description": "ContactForm: object label"
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 04ae617..2a886aa 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -4,12 +4,11 @@ import type { GetStaticPaths, GetStaticProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import Script from 'next/script';
+import { useCallback } from 'react';
import { useIntl } from 'react-intl';
-import type { Comment as CommentSchema, WithContext } from 'schema-dts';
import {
getLayout,
SharingWidget,
- Spinner,
type CommentData,
Heading,
Page,
@@ -19,24 +18,30 @@ import {
PageComments,
PageSidebar,
TocWidget,
+ LoadingPage,
+ LoadingPageComments,
} from '../../components';
import {
- convertPostToArticle,
- convertWPCommentToComment,
fetchAllPostsSlugs,
fetchCommentsList,
fetchPost,
fetchPostsCount,
} from '../../services/graphql';
-import styles from '../../styles/pages/article.module.scss';
-import type { Article, NextPageWithLayout, SingleComment } from '../../types';
+import styles from '../../styles/pages/blog.module.scss';
+import type {
+ NextPageWithLayout,
+ SingleComment,
+ WPComment,
+ WPPost,
+} from '../../types';
import { CONFIG } from '../../utils/config';
-import { ROUTES } from '../../utils/constants';
import {
getBlogSchema,
+ getCommentsSchema,
getSchemaJson,
getSinglePageSchema,
getWebPageSchema,
+ updateWordPressCodeBlocks,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
@@ -48,48 +53,33 @@ import {
} from '../../utils/hooks';
type ArticlePageProps = {
- comments: SingleComment[];
- post: Article;
- slug: string;
+ data: {
+ comments: WPComment[];
+ post: WPPost;
+ };
translation: Messages;
};
/**
* Article page.
*/
-const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
- comments,
- post,
- slug,
-}) => {
- const { isFallback } = useRouter();
+const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
const intl = useIntl();
- const article = useArticle({ slug, fallback: post });
- const commentsData = useComments({
- fallback: comments,
- first: article?.meta.commentsCount,
+ const { isFallback } = useRouter();
+ const { article, isLoading } = useArticle(data.post.slug, data.post);
+ const { comments, isLoading: areCommentsLoading } = useComments({
+ fallback: data.comments,
+ first: article.meta.commentsCount,
where: {
- contentId: article?.id ?? post.id,
+ contentId: article.id,
},
});
-
- const getComments = (data?: SingleComment[]) =>
- data?.map((comment): CommentData => {
- return {
- author: comment.meta.author,
- content: comment.content,
- id: comment.id,
- isApproved: comment.isApproved,
- publicationDate: comment.meta.date,
- replies: getComments(comment.replies),
- };
- });
-
const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: article?.title ?? '',
- url: `${ROUTES.ARTICLE}/${slug}`,
+ title: data.post.title,
+ url: data.post.slug,
});
- const { attributes, className } = usePrism({
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+ const { attributes, className: prismClassName } = usePrism({
attributes: {
'data-toolbar-order': 'show-language,copy-to-clipboard,color-scheme',
},
@@ -106,14 +96,41 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
'line-numbers',
],
});
- const loadingArticle = intl.formatMessage({
- defaultMessage: 'Loading the requested article...',
- description: 'ArticlePage: loading article message',
- id: '4iYISO',
- });
- const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
- if (isFallback || !article) return <Spinner>{loadingArticle}</Spinner>;
+ const formatComments = useCallback(
+ (allComments: SingleComment[]) =>
+ allComments.map((comment): CommentData => {
+ return {
+ author: {
+ ...comment.meta.author,
+ avatar: comment.meta.author.avatar
+ ? {
+ ...comment.meta.author.avatar,
+ alt: intl.formatMessage(
+ {
+ defaultMessage: "{author}'s avatar",
+ description:
+ 'Article: accessible name for the comment avatar',
+ id: 'VTJE8h',
+ },
+ {
+ author: comment.meta.author.name,
+ }
+ ),
+ }
+ : undefined,
+ },
+ content: comment.content,
+ id: comment.id,
+ isApproved: comment.isApproved,
+ publicationDate: comment.meta.date,
+ replies: formatComments(comment.replies),
+ };
+ }),
+ [intl]
+ );
+
+ if (isFallback || isLoading) return <LoadingPage />;
const { content, id, intro, meta, title } = article;
const {
@@ -130,14 +147,14 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
const webpageSchema = getWebPageSchema({
description: intro,
locale: CONFIG.locales.defaultLocale,
- slug,
+ slug: article.slug,
title,
updateDate: dates.update,
});
const blogSchema = getBlogSchema({
isSinglePage: true,
locale: CONFIG.locales.defaultLocale,
- slug,
+ slug: article.slug,
});
const blogPostSchema = getSinglePageSchema({
commentsCount,
@@ -148,90 +165,30 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
id: 'article',
kind: 'post',
locale: CONFIG.locales.defaultLocale,
- slug,
+ slug: article.slug,
title,
});
- const commentsSchema: WithContext<CommentSchema>[] = commentsData
- ? commentsData.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,
- };
- })
- : [];
const schemaJsonLd = getSchemaJson([
webpageSchema,
blogSchema,
blogPostSchema,
- ...commentsSchema,
+ ...getCommentsSchema(comments),
]);
- const lineNumbersClassName = className
- .replace('command-line', '')
- .replace(/\s\s+/g, ' ');
- const commandLineClassName = className
- .replace('line-numbers', '')
- .replace(/\s\s+/g, ' ');
-
- /**
- * Replace a string with Prism classnames and attributes.
- *
- * @param {string} str - The found string.
- * @returns {string} The classes and attributes.
- */
- const prismClassNameReplacer = (str: string): string => {
- const wpBlockClassName = 'wp-block-code';
- const languageArray = /language-[^\s|"]+/.exec(str);
- const languageClassName = languageArray ? `${languageArray[0]}` : '';
-
- if (
- str.includes('command-line') ||
- (!str.includes('command-line') && str.includes('language-bash'))
- ) {
- return `class="${wpBlockClassName} ${commandLineClassName} ${languageClassName}" tabindex="0" data-filter-output="#output#`;
- }
-
- return `class="${wpBlockClassName} ${lineNumbersClassName} ${languageClassName}" tabindex="0`;
+ const pageUrl = `${CONFIG.url}${article.slug}`;
+ const messages = {
+ sharingTitle: intl.formatMessage({
+ defaultMessage: 'Share',
+ id: 's57FTB',
+ description: 'Article: sharing widget title',
+ }),
+ tocTitle: intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ }),
};
- const contentWithPrismClasses = content.replaceAll(
- /class="wp-block-code[^"]+/gm,
- prismClassNameReplacer
- );
-
- const pageUrl = `${CONFIG.url}${slug}`;
- const sharingWidgetTitle = intl.formatMessage({
- defaultMessage: 'Share',
- id: 'HKKkQk',
- description: 'SharingWidget: widget title',
- });
- const tocTitle = intl.formatMessage({
- defaultMessage: 'Table of Contents',
- description: 'PageLayout: table of contents title',
- id: 'eys2uX',
- });
- const articleComments = getComments(commentsData);
-
return (
<Page breadcrumbs={breadcrumbItems}>
<Head>
@@ -270,14 +227,16 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
/>
<PageSidebar>
<TocWidget
- heading={<Heading level={3}>{tocTitle}</Heading>}
+ heading={<Heading level={2}>{messages.tocTitle}</Heading>}
tree={tree}
/>
</PageSidebar>
<PageBody
{...attributes}
className={styles.body}
- dangerouslySetInnerHTML={{ __html: contentWithPrismClasses }}
+ dangerouslySetInnerHTML={{
+ __html: updateWordPressCodeBlocks(content, prismClassName),
+ }}
ref={ref}
/>
{topics ? <PageFooter readMoreAbout={topics} /> : null}
@@ -285,9 +244,9 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
<SharingWidget
// eslint-disable-next-line react/jsx-no-literals -- Key allowed
key="sharing-widget"
- className={styles.widget}
+ className={styles['sharing-widget']}
data={{ excerpt: intro, title, url: pageUrl }}
- heading={<Heading level={3}>{sharingWidgetTitle}</Heading>}
+ heading={<Heading level={2}>{messages.sharingTitle}</Heading>}
media={[
'diaspora',
'email',
@@ -298,7 +257,15 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
]}
/>
</PageSidebar>
- <PageComments comments={articleComments ?? []} depth={2} pageId={id} />
+ {areCommentsLoading ? (
+ <LoadingPageComments />
+ ) : (
+ <PageComments
+ comments={formatComments(comments)}
+ depth={2}
+ pageId={id}
+ />
+ )}
</Page>
);
};
@@ -314,7 +281,6 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
params,
}) => {
const post = await fetchPost((params as PostParams).slug);
- const article = await convertPostToArticle(post);
const comments = await fetchCommentsList({
first: post.commentCount ?? 1,
where: { contentId: post.databaseId },
@@ -323,11 +289,10 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
return {
props: {
- comments: JSON.parse(
- JSON.stringify(comments.map(convertWPCommentToComment))
- ),
- post: JSON.parse(JSON.stringify(article)),
- slug: post.slug,
+ data: {
+ comments: JSON.parse(JSON.stringify(comments)),
+ post: JSON.parse(JSON.stringify(post)),
+ },
translation,
},
};
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 3d1e966..487b18b 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -59,7 +59,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
const webpageSchema = getWebPageSchema({
description: seo.description,
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
+ slug,
title: seo.title,
updateDate: dates.update,
});
@@ -69,7 +69,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
id: 'thematic',
kind: 'page',
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
+ slug,
title,
});
const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
diff --git a/src/services/graphql/helpers/convert-post-to-article.test.ts b/src/services/graphql/helpers/convert-post-to-article.test.ts
index 0a1c359..9fd74af 100644
--- a/src/services/graphql/helpers/convert-post-to-article.test.ts
+++ b/src/services/graphql/helpers/convert-post-to-article.test.ts
@@ -1,11 +1,12 @@
import { describe, expect, it } from '@jest/globals';
import type { WPPost } from '../../../types';
+import { ROUTES } from '../../../utils/constants';
import { convertPostToArticle } from './convert-post-to-article';
import { convertWPImgToImg } from './convert-wp-image-to-img';
describe('convert-post-to-article', () => {
/* eslint-disable max-statements */
- it('converts a WPPost object to an Article object', async () => {
+ it('converts a WPPost object to an Article object', () => {
const post: WPPost = {
acfPosts: null,
author: { node: { name: 'Vince5' } },
@@ -28,10 +29,7 @@ describe('convert-post-to-article', () => {
slug: '/the-post-slug',
title: 'ea vero repellat',
};
- const result = await convertPostToArticle(post);
-
- // eslint-disable-next-line @typescript-eslint/no-magic-numbers
- expect.assertions(15);
+ const result = convertPostToArticle(post);
expect(result.content).toBe(post.contentParts.afterMore);
expect(result.id).toBe(post.databaseId);
@@ -46,12 +44,12 @@ describe('convert-post-to-article', () => {
expect(result.meta.thematics).toBeUndefined();
expect(result.meta.topics).toBeUndefined();
expect(result.meta.wordsCount).toBe(post.info.wordsCount);
- expect(result.slug).toBe(post.slug);
+ expect(result.slug).toBe(`${ROUTES.ARTICLE}/${post.slug}`);
expect(result.title).toBe(post.title);
});
/* eslint-enable max-statements */
- it('can convert the cover', async () => {
+ it('can convert the cover', () => {
const post = {
acfPosts: null,
author: { node: { name: 'Vince5' } },
@@ -84,16 +82,14 @@ describe('convert-post-to-article', () => {
slug: '/the-post-slug',
title: 'ea vero repellat',
} satisfies WPPost;
- const result = await convertPostToArticle(post);
-
- expect.assertions(1);
+ const result = convertPostToArticle(post);
expect(result.meta.cover).toStrictEqual(
convertWPImgToImg(post.featuredImage.node)
);
});
- it('can return 0 as comment count when not defined', async () => {
+ it('can return 0 as comment count when not defined', () => {
const post: WPPost = {
acfPosts: null,
author: { node: { name: 'Vince5' } },
@@ -116,9 +112,7 @@ describe('convert-post-to-article', () => {
slug: '/the-post-slug',
title: 'ea vero repellat',
};
- const result = await convertPostToArticle(post);
-
- expect.assertions(1);
+ const result = convertPostToArticle(post);
expect(result.meta.commentsCount).toBe(0);
});
diff --git a/src/services/graphql/helpers/convert-post-to-article.ts b/src/services/graphql/helpers/convert-post-to-article.ts
index 383dc47..14c572d 100644
--- a/src/services/graphql/helpers/convert-post-to-article.ts
+++ b/src/services/graphql/helpers/convert-post-to-article.ts
@@ -1,4 +1,5 @@
import type { Article, WPPost } from '../../../types';
+import { ROUTES } from '../../../utils/constants';
import { updateContentTree } from '../../../utils/helpers';
import {
convertWPThematicPreviewToPageLink,
@@ -6,7 +7,7 @@ import {
} from './convert-taxonomy-to-page-link';
import { convertWPImgToImg } from './convert-wp-image-to-img';
-export const convertPostToArticle = async ({
+export const convertPostToArticle = ({
acfPosts,
author,
commentCount,
@@ -19,9 +20,9 @@ export const convertPostToArticle = async ({
seo,
slug,
title,
-}: WPPost): Promise<Article> => {
+}: WPPost): Article => {
return {
- content: await updateContentTree(contentParts.afterMore),
+ content: updateContentTree(contentParts.afterMore),
id: databaseId,
intro: contentParts.beforeMore,
meta: {
@@ -42,7 +43,7 @@ export const convertPostToArticle = async ({
topics: acfPosts?.postsInTopic?.map(convertWPTopicPreviewToPageLink),
wordsCount: info.wordsCount,
},
- slug,
+ slug: `${ROUTES.ARTICLE}/${slug}`,
title,
};
};
diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss
deleted file mode 100644
index 7aac5a7..0000000
--- a/src/styles/pages/article.module.scss
+++ /dev/null
@@ -1,74 +0,0 @@
-@use "../abstracts/functions" as fun;
-@use "../abstracts/mixins" as mix;
-@use "../abstracts/placeholders";
-@use "partials/article-headings";
-@use "partials/article-links";
-@use "partials/article-lists";
-@use "partials/article-media";
-@use "partials/article-wp-blocks";
-
-.btn {
- margin-right: var(--spacing-2xs);
- padding: var(--spacing-2xs) var(--spacing-xs);
-
- img {
- max-width: fun.convert-px(22);
- }
-}
-
-.body {
- :global {
- @include article-headings.styles;
- @include article-links.styles;
- @include article-lists.styles;
- @include article-media.styles;
- @include article-wp-blocks.styles;
- @extend %prism;
- }
-}
-
-:global([data-theme="light"]) {
- :local {
- .body {
- :global {
- a {
- &.download {
- @extend %light-download-link;
- }
-
- &.external {
- @extend %light-external-link;
- }
- }
- }
- }
- }
-}
-
-:global([data-theme="dark"]) {
- :local {
- .body {
- :global {
- a {
- &.download {
- @extend %dark-download-link;
- }
-
- &.external {
- @extend %dark-external-link;
- }
- }
- }
- }
- }
-}
-
-.widget {
- @include mix.media("screen") {
- @include mix.dimensions("md") {
- ul {
- width: min-content;
- }
- }
- }
-}
diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss
index e099088..aebf263 100644
--- a/src/styles/pages/blog.module.scss
+++ b/src/styles/pages/blog.module.scss
@@ -1,7 +1,11 @@
@use "../abstracts/functions" as fun;
@use "../abstracts/mixins" as mix;
@use "../abstracts/placeholders";
+@use "partials/article-headings";
@use "partials/article-links";
+@use "partials/article-lists";
+@use "partials/article-media";
+@use "partials/article-wp-blocks";
.list {
@include mix.media("screen") {
@@ -14,20 +18,29 @@
}
}
+.sharing-widget {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ ul {
+ width: min-content;
+ }
+ }
+ }
+}
+
.logo {
max-width: fun.convert-px(50);
margin: 0 var(--spacing-xs) 0 0;
}
-:where(.body) {
+.body {
:global {
+ @include article-headings.styles;
@include article-links.styles;
-
- h2 {
- @extend %h2;
-
- margin-block-end: var(--spacing-sm);
- }
+ @include article-lists.styles;
+ @include article-media.styles;
+ @include article-wp-blocks.styles;
+ @extend %prism;
}
}
diff --git a/src/styles/pages/partials/_article-headings.scss b/src/styles/pages/partials/_article-headings.scss
index 7a273e4..234c426 100644
--- a/src/styles/pages/partials/_article-headings.scss
+++ b/src/styles/pages/partials/_article-headings.scss
@@ -31,8 +31,10 @@
h4,
h5,
h6 {
- &:not(:first-child) {
- margin-block: var(--spacing-sm);
+ margin-block: var(--spacing-sm);
+
+ &:first-of-type {
+ margin-block-start: var(--spacing-md);
}
}
}
diff --git a/src/styles/pages/partials/_article-wp-blocks.scss b/src/styles/pages/partials/_article-wp-blocks.scss
index f23be05..e4e89ec 100644
--- a/src/styles/pages/partials/_article-wp-blocks.scss
+++ b/src/styles/pages/partials/_article-wp-blocks.scss
@@ -75,7 +75,7 @@
display: flex;
flex-flow: column;
width: fit-content;
- margin: 0 auto;
+ margin: var(--spacing-sm) auto;
position: relative;
text-align: center;
@@ -172,9 +172,13 @@
}
}
- .wp-block-image img {
- height: 100%;
- object-fit: cover;
+ .wp-block-image {
+ margin: 0;
+
+ img {
+ height: 100%;
+ object-fit: cover;
+ }
}
}
}
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>;
+};
diff --git a/tests/cypress/e2e/pages/article.cy.ts b/tests/cypress/e2e/pages/article.cy.ts
new file mode 100644
index 0000000..cf64015
--- /dev/null
+++ b/tests/cypress/e2e/pages/article.cy.ts
@@ -0,0 +1,49 @@
+import { ROUTES } from '../../../../src/utils/constants';
+
+describe('Article', () => {
+ beforeEach(() => {
+ cy.visit(ROUTES.HOME);
+ cy.findAllByRole('link', { name: /^Consulter/i }).then(($articles) =>
+ $articles[0].click()
+ );
+ });
+
+ it('successfully loads', () => {
+ cy.findByRole('heading', { level: 1 }).should('exist');
+ });
+
+ it('contains the article meta', () => {
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ cy.findAllByRole('term').should('have.length.at.least', 3);
+
+ /* The accessible name is not recognized while it should be the `dt` text
+ * content */
+ /* cy.findByRole('term', { name: 'Écrit par :' }).should('exist');
+ cy.findByRole('term', { name: 'Publié le :' }).should('exist');
+ cy.findByRole('term', { name: 'Temps de lecture :' }).should('exist'); */
+ });
+
+ it('contains a breadcrumbs', () => {
+ cy.findByRole('navigation', { name: 'Fil d’Ariane' }).should('exist');
+ });
+
+ it('contains a table of contents', () => {
+ cy.findByRole('heading', { level: 2, name: 'Table des matières' }).should(
+ 'exist'
+ );
+ });
+
+ it('contains a sharing widget', () => {
+ cy.findByRole('heading', { level: 2, name: 'Partager' }).should('exist');
+ });
+
+ it('contains a comments section', () => {
+ cy.findByRole('heading', {
+ level: 2,
+ name: 'Laisser un commentaire',
+ }).should('exist');
+ cy.findByRole('form', { name: 'Formulaire des commentaires' }).should(
+ 'exist'
+ );
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index c4ddaee..b29589f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4225,7 +4225,7 @@
dependencies:
"@types/node" "*"
-"@types/hast@^3.0.0":
+"@types/hast@^3.0.0", "@types/hast@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.3.tgz#7f75e6b43bc3f90316046a287d9ad3888309f7e1"
integrity sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ==
@@ -8973,6 +8973,14 @@ hasown@^2.0.0:
dependencies:
function-bind "^1.1.2"
+hast-util-classnames@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/hast-util-classnames/-/hast-util-classnames-3.0.0.tgz#79d1e2c49fd0b2f4213f12048cb7a0439c351c8b"
+ integrity sha512-tI3JjoGDEBVorMAWK4jNRsfLMYmih1BUOG3VV36pH36njs1IEl7xkNrVTD2mD2yYHmQCa5R/fj61a8IAF4bRaQ==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ space-separated-tokens "^2.0.0"
+
hast-util-from-html@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-2.0.1.tgz#9cd38ee81bf40b2607368b92a04b0905fa987488"