From b836f0a9f8b783e3328983ad087aa2b7b297b43a Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 15 Apr 2022 14:38:54 +0200
Subject: chore: add a CommentForm component

---
 src/components/organisms/forms/comment-form.tsx | 174 ++++++++++++++++++++++++
 1 file changed, 174 insertions(+)
 create mode 100644 src/components/organisms/forms/comment-form.tsx

(limited to 'src/components/organisms/forms/comment-form.tsx')

diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
new file mode 100644
index 0000000..6acbf94
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -0,0 +1,174 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { ReactNode, useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './comment-form.module.scss';
+
+export type CommentFormProps = {
+  /**
+   * Set additional classnames to the form wrapper.
+   */
+  className?: string;
+  /**
+   * Pass a component to print a success/error message.
+   */
+  Notice?: ReactNode;
+  /**
+   * A callback function to save comment. It takes a function as parameter to
+   * reset the form.
+   */
+  saveComment: (reset: () => void) => void;
+  /**
+   * The form title.
+   */
+  title?: string;
+  /**
+   * The title level.
+   */
+  titleLevel?: HeadingLevel;
+};
+
+const CommentForm: VFC<CommentFormProps> = ({
+  className = '',
+  Notice,
+  saveComment,
+  title,
+  titleLevel = 2,
+}) => {
+  const intl = useIntl();
+  const [name, setName] = useState<string>('');
+  const [email, setEmail] = useState<string>('');
+  const [website, setWebsite] = useState<string>('');
+  const [comment, setComment] = useState<string>('');
+  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+  /**
+   * Reset all the form fields.
+   */
+  const resetForm = () => {
+    setName('');
+    setEmail('');
+    setWebsite('');
+    setComment('');
+    setIsSubmitting(false);
+  };
+
+  const nameLabel = intl.formatMessage({
+    defaultMessage: 'Name:',
+    description: 'CommentForm: name label',
+    id: 'ZIrTee',
+  });
+
+  const emailLabel = intl.formatMessage({
+    defaultMessage: 'Email:',
+    description: 'CommentForm: email label',
+    id: 'Bh7z5v',
+  });
+
+  const websiteLabel = intl.formatMessage({
+    defaultMessage: 'Website:',
+    description: 'CommentForm: website label',
+    id: 'u41qSk',
+  });
+
+  const commentLabel = intl.formatMessage({
+    defaultMessage: 'Comment:',
+    description: 'CommentForm: comment label',
+    id: 'A8hGaK',
+  });
+
+  const formTitle = intl.formatMessage({
+    defaultMessage: 'Comment form',
+    description: 'CommentForm: aria label',
+    id: 'dz2kDV',
+  });
+
+  const formAriaLabel = title ? undefined : formTitle;
+  const formId = 'comment-form-title';
+  const formLabelledBy = title ? formId : undefined;
+
+  /**
+   * Handle form submit.
+   */
+  const submitHandler = () => {
+    setIsSubmitting(true);
+    saveComment(resetForm);
+  };
+
+  return (
+    <Form
+      onSubmit={submitHandler}
+      className={className}
+      aria-label={formAriaLabel}
+      aria-labelledby={formLabelledBy}
+    >
+      {title && (
+        <Heading id={formId} level={titleLevel}>
+          {title}
+        </Heading>
+      )}
+      <LabelledField
+        type="text"
+        id="commenter-name"
+        name="commenter-name"
+        label={nameLabel}
+        required={true}
+        value={name}
+        setValue={setName}
+        className={styles.field}
+      />
+      <LabelledField
+        type="email"
+        id="commenter-email"
+        name="commenter-email"
+        label={emailLabel}
+        required={true}
+        value={email}
+        setValue={setEmail}
+        className={styles.field}
+      />
+      <LabelledField
+        type="text"
+        id="commenter-website"
+        name="commenter-website"
+        label={websiteLabel}
+        required={false}
+        value={website}
+        setValue={setWebsite}
+        className={styles.field}
+      />
+      <LabelledField
+        type="textarea"
+        id="commenter-comment"
+        name="commenter-comment"
+        label={commentLabel}
+        required={true}
+        value={comment}
+        setValue={setComment}
+        className={styles.field}
+      />
+      <Button type="submit" kind="primary" className={styles.button}>
+        {intl.formatMessage({
+          defaultMessage: 'Publish',
+          description: 'CommentForm: submit button',
+          id: 'OL0Yzx',
+        })}
+      </Button>
+      {isSubmitting && (
+        <Spinner
+          message={intl.formatMessage({
+            defaultMessage: 'Submitting...',
+            description: 'CommentForm: spinner message on submit',
+            id: 'IY5ew6',
+          })}
+        />
+      )}
+      {Notice}
+    </Form>
+  );
+};
+
+export default CommentForm;
-- 
cgit v1.2.3


From 5a6e4eea16047083e2de0e91a1b3ed9be8d6eb68 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sat, 16 Apr 2022 16:08:49 +0200
Subject: refactor: support React 18

I replaced the deprecated VFC type with FC type and made all children
explicits.
Formatjs is still not compatible with React 18 so I need to skip type
checking when comitting. There are some type errors because of
IntlProvider in Storybook stories.
---
 jest.setup.js                                      |  1 +
 src/components/atoms/buttons/button-link.tsx       |  6 +-
 src/components/atoms/buttons/button.tsx            |  6 +-
 src/components/atoms/forms/checkbox.tsx            |  4 +-
 src/components/atoms/forms/field.tsx               |  4 +-
 src/components/atoms/forms/form.test.tsx           |  6 +-
 src/components/atoms/forms/form.tsx                |  6 +-
 src/components/atoms/forms/label.tsx               |  6 +-
 src/components/atoms/forms/select.tsx              |  4 +-
 src/components/atoms/headings/heading.tsx          |  6 +-
 src/components/atoms/icons/arrow.tsx               |  4 +-
 src/components/atoms/icons/career.tsx              |  4 +-
 src/components/atoms/icons/cc-by-sa.tsx            |  4 +-
 src/components/atoms/icons/close.tsx               |  4 +-
 src/components/atoms/icons/cog.tsx                 |  4 +-
 src/components/atoms/icons/computer-screen.tsx     |  4 +-
 src/components/atoms/icons/envelop.tsx             |  4 +-
 src/components/atoms/icons/hamburger.tsx           |  2 +-
 src/components/atoms/icons/home.tsx                |  4 +-
 src/components/atoms/icons/magnifying-glass.tsx    |  4 +-
 src/components/atoms/icons/moon.tsx                |  6 +-
 src/components/atoms/icons/plus-minus.tsx          |  2 +-
 src/components/atoms/icons/posts-stack.tsx         |  4 +-
 src/components/atoms/icons/sun.tsx                 |  6 +-
 src/components/atoms/images/logo.tsx               |  6 +-
 src/components/atoms/layout/copyright.tsx          |  4 +-
 src/components/atoms/layout/main.tsx               |  6 +-
 src/components/atoms/layout/no-script.tsx          |  4 +-
 src/components/atoms/layout/notice.tsx             |  4 +-
 src/components/atoms/layout/section.tsx            |  4 +-
 src/components/atoms/links/link.module.scss        | 20 ++++--
 src/components/atoms/links/link.tsx                |  6 +-
 src/components/atoms/links/nav-link.tsx            |  4 +-
 src/components/atoms/links/sharing-link.tsx        |  4 +-
 src/components/atoms/links/social-link.tsx         |  4 +-
 src/components/atoms/lists/description-list.tsx    |  4 +-
 src/components/atoms/lists/list.tsx                |  4 +-
 src/components/atoms/loaders/progress-bar.tsx      |  4 +-
 src/components/atoms/loaders/spinner.tsx           |  4 +-
 src/components/molecules/buttons/back-to-top.tsx   | 14 ++--
 .../molecules/buttons/heading-button.tsx           |  4 +-
 src/components/molecules/buttons/help-button.tsx   |  6 +-
 .../molecules/forms/ackee-select.test.tsx          | 16 +++--
 src/components/molecules/forms/ackee-select.tsx    |  6 +-
 src/components/molecules/forms/labelled-field.tsx  |  4 +-
 src/components/molecules/forms/labelled-select.tsx | 15 +++--
 src/components/molecules/forms/motion-toggle.tsx   |  8 +--
 .../molecules/forms/prism-theme-toggle.tsx         |  8 +--
 .../molecules/forms/select-with-tooltip.tsx        | 10 +--
 src/components/molecules/forms/theme-toggle.tsx    |  8 +--
 src/components/molecules/forms/toggle.tsx          | 18 ++---
 .../molecules/images/responsive-image.tsx          | 10 +--
 src/components/molecules/layout/branding.tsx       | 12 ++--
 src/components/molecules/layout/card.tsx           |  8 +--
 src/components/molecules/layout/flipping-logo.tsx  | 10 +--
 src/components/molecules/layout/meta.tsx           |  4 +-
 src/components/molecules/layout/widget.tsx         | 10 ++-
 src/components/molecules/modals/modal.test.tsx     | 13 +++-
 src/components/molecules/modals/modal.tsx          | 14 ++--
 src/components/molecules/modals/tooltip.tsx        |  4 +-
 src/components/molecules/nav/breadcrumb.tsx        |  4 +-
 src/components/molecules/nav/nav.tsx               |  4 +-
 .../organisms/forms/comment-form.stories.tsx       |  1 -
 src/components/organisms/forms/comment-form.tsx    |  4 +-
 src/components/organisms/forms/contact-form.tsx    |  4 +-
 src/components/organisms/forms/search-form.tsx     | 11 +++-
 src/components/organisms/layout/cards-list.tsx     |  4 +-
 src/components/organisms/layout/footer.tsx         | 13 ++--
 src/components/organisms/layout/overview.tsx       |  4 +-
 .../organisms/layout/summary.stories.tsx           |  2 +-
 src/components/organisms/layout/summary.test.tsx   |  2 +-
 src/components/organisms/layout/summary.tsx        | 77 +++++++++++++---------
 src/components/organisms/modals/search-modal.tsx   |  8 +--
 src/components/organisms/modals/settings-modal.tsx | 14 ++--
 src/components/organisms/toolbar/main-nav.tsx      | 18 +++--
 src/components/organisms/toolbar/search.tsx        | 16 ++---
 src/components/organisms/toolbar/settings.tsx      | 14 ++--
 src/components/organisms/toolbar/toolbar.tsx       |  4 +-
 src/components/organisms/widgets/image-widget.tsx  | 34 ++++------
 .../organisms/widgets/links-list-widget.tsx        |  9 ++-
 src/components/organisms/widgets/sharing.tsx       |  6 +-
 81 files changed, 353 insertions(+), 289 deletions(-)

(limited to 'src/components/organisms/forms/comment-form.tsx')

diff --git a/jest.setup.js b/jest.setup.js
index 5c5ecbc..d50c988 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -2,3 +2,4 @@ import '@testing-library/jest-dom/extend-expect';
 import './__tests__/jest/__mocks__/matchMedia.mock';
 
 jest.mock('next/dist/client/router', () => require('next-router-mock'));
+jest.mock('next/dynamic', () => () => 'dynamic-import');
diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx
index 77a7f7b..906fa76 100644
--- a/src/components/atoms/buttons/button-link.tsx
+++ b/src/components/atoms/buttons/button-link.tsx
@@ -1,5 +1,5 @@
 import Link from 'next/link';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './buttons.module.scss';
 
 export type ButtonLinkProps = {
@@ -7,6 +7,10 @@ export type ButtonLinkProps = {
    * ButtonLink accessible label.
    */
   'aria-label'?: string;
+  /**
+   * The button link body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the button link.
    */
diff --git a/src/components/atoms/buttons/button.tsx b/src/components/atoms/buttons/button.tsx
index 545c5c5..a6eef8b 100644
--- a/src/components/atoms/buttons/button.tsx
+++ b/src/components/atoms/buttons/button.tsx
@@ -1,7 +1,11 @@
-import { FC, MouseEventHandler } from 'react';
+import { FC, MouseEventHandler, ReactNode } from 'react';
 import styles from './buttons.module.scss';
 
 export type ButtonProps = {
+  /**
+   * The button body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the button wrapper.
    */
diff --git a/src/components/atoms/forms/checkbox.tsx b/src/components/atoms/forms/checkbox.tsx
index 8babcc8..aec97f0 100644
--- a/src/components/atoms/forms/checkbox.tsx
+++ b/src/components/atoms/forms/checkbox.tsx
@@ -1,4 +1,4 @@
-import { SetStateAction, VFC } from 'react';
+import { FC, SetStateAction } from 'react';
 
 export type CheckboxProps = {
   /**
@@ -32,7 +32,7 @@ export type CheckboxProps = {
  *
  * Render a checkbox type input.
  */
-const Checkbox: VFC<CheckboxProps> = ({ value, setValue, ...props }) => {
+const Checkbox: FC<CheckboxProps> = ({ value, setValue, ...props }) => {
   return (
     <input
       type="checkbox"
diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx
index 2e75d0f..e45a8a7 100644
--- a/src/components/atoms/forms/field.tsx
+++ b/src/components/atoms/forms/field.tsx
@@ -1,4 +1,4 @@
-import { ChangeEvent, SetStateAction, VFC } from 'react';
+import { ChangeEvent, FC, SetStateAction } from 'react';
 import styles from './forms.module.scss';
 
 export type FieldType =
@@ -72,7 +72,7 @@ export type FieldProps = {
  *
  * Render either an input or a textarea.
  */
-const Field: VFC<FieldProps> = ({
+const Field: FC<FieldProps> = ({
   className = '',
   setValue,
   type,
diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx
index 9cd3c58..8b534f1 100644
--- a/src/components/atoms/forms/form.test.tsx
+++ b/src/components/atoms/forms/form.test.tsx
@@ -3,7 +3,11 @@ import Form from './form';
 
 describe('Form', () => {
   it('renders a form', () => {
-    render(<Form aria-label="Jest form" onSubmit={() => null}></Form>);
+    render(
+      <Form aria-label="Jest form" onSubmit={() => null}>
+        Fields
+      </Form>
+    );
     expect(screen.getByRole('form', { name: 'Jest form' })).toBeInTheDocument();
   });
 });
diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
index 8e80930..ef8dce4 100644
--- a/src/components/atoms/forms/form.tsx
+++ b/src/components/atoms/forms/form.tsx
@@ -1,4 +1,4 @@
-import { Children, FC, FormEvent, Fragment } from 'react';
+import { Children, FC, FormEvent, Fragment, ReactNode } from 'react';
 import styles from './forms.module.scss';
 
 export type FormProps = {
@@ -10,6 +10,10 @@ export type FormProps = {
    * One or more ids that refers to the form name.
    */
   'aria-labelledby'?: string;
+  /**
+   * The form body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the form wrapper.
    */
diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx
index 8d57ee2..ce4c70f 100644
--- a/src/components/atoms/forms/label.tsx
+++ b/src/components/atoms/forms/label.tsx
@@ -1,7 +1,11 @@
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './label.module.scss';
 
 export type LabelProps = {
+  /**
+   * The label body.
+   */
+  children: ReactNode;
   /**
    * Add classnames to the label.
    */
diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx
index 25e86e0..dbe9b37 100644
--- a/src/components/atoms/forms/select.tsx
+++ b/src/components/atoms/forms/select.tsx
@@ -1,4 +1,4 @@
-import { ChangeEvent, SetStateAction, VFC } from 'react';
+import { ChangeEvent, FC, SetStateAction } from 'react';
 import styles from './forms.module.scss';
 
 export type SelectOptions = {
@@ -60,7 +60,7 @@ export type SelectProps = {
  *
  * Render a HTML select element.
  */
-const Select: VFC<SelectProps> = ({
+const Select: FC<SelectProps> = ({
   className = '',
   options,
   setValue,
diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx
index 4703b5d..c5bf4ca 100644
--- a/src/components/atoms/headings/heading.tsx
+++ b/src/components/atoms/headings/heading.tsx
@@ -1,9 +1,13 @@
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './heading.module.scss';
 
 export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
 
 export type HeadingProps = {
+  /**
+   * The heading body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames.
    */
diff --git a/src/components/atoms/icons/arrow.tsx b/src/components/atoms/icons/arrow.tsx
index 5f3c460..2962530 100644
--- a/src/components/atoms/icons/arrow.tsx
+++ b/src/components/atoms/icons/arrow.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './arrow.module.scss';
 
 export type ArrowDirection = 'top' | 'right' | 'bottom' | 'left';
@@ -19,7 +19,7 @@ export type ArrowProps = {
  *
  * Render a svg arrow icon.
  */
-const Arrow: VFC<ArrowProps> = ({ className = '', direction }) => {
+const Arrow: FC<ArrowProps> = ({ className = '', direction }) => {
   const directionClass = styles[`icon--${direction}`];
   const classes = `${styles.icon} ${directionClass} ${className}`;
 
diff --git a/src/components/atoms/icons/career.tsx b/src/components/atoms/icons/career.tsx
index 28edcc7..f28d399 100644
--- a/src/components/atoms/icons/career.tsx
+++ b/src/components/atoms/icons/career.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './career.module.scss';
 
 export type CareerProps = {
@@ -13,7 +13,7 @@ export type CareerProps = {
  *
  * Render a career svg icon.
  */
-const Career: VFC<CareerProps> = ({ className = '' }) => {
+const Career: FC<CareerProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/cc-by-sa.tsx b/src/components/atoms/icons/cc-by-sa.tsx
index 552504e..8239154 100644
--- a/src/components/atoms/icons/cc-by-sa.tsx
+++ b/src/components/atoms/icons/cc-by-sa.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './cc-by-sa.module.scss';
 
@@ -14,7 +14,7 @@ export type CCBySAProps = {
  *
  * Render a CC BY SA svg icon.
  */
-const CCBySA: VFC<CCBySAProps> = ({ className = '' }) => {
+const CCBySA: FC<CCBySAProps> = ({ className = '' }) => {
   const intl = useIntl();
 
   return (
diff --git a/src/components/atoms/icons/close.tsx b/src/components/atoms/icons/close.tsx
index eb9ce7c..3e0adb5 100644
--- a/src/components/atoms/icons/close.tsx
+++ b/src/components/atoms/icons/close.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './close.module.scss';
 
 export type CloseProps = {
@@ -13,7 +13,7 @@ export type CloseProps = {
  *
  * Render a close svg icon.
  */
-const Close: VFC<CloseProps> = ({ className = '' }) => {
+const Close: FC<CloseProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/cog.tsx b/src/components/atoms/icons/cog.tsx
index df6d54d..9e78a7b 100644
--- a/src/components/atoms/icons/cog.tsx
+++ b/src/components/atoms/icons/cog.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './cog.module.scss';
 
 export type CogProps = {
@@ -13,7 +13,7 @@ export type CogProps = {
  *
  * Render a cog svg icon.
  */
-const Cog: VFC<CogProps> = ({ className = '' }) => {
+const Cog: FC<CogProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/computer-screen.tsx b/src/components/atoms/icons/computer-screen.tsx
index 310836f..8786139 100644
--- a/src/components/atoms/icons/computer-screen.tsx
+++ b/src/components/atoms/icons/computer-screen.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './computer-screen.module.scss';
 
 export type ComputerScreenProps = {
@@ -13,7 +13,7 @@ export type ComputerScreenProps = {
  *
  * Render a computer screen svg icon.
  */
-const ComputerScreen: VFC<ComputerScreenProps> = ({ className = '' }) => {
+const ComputerScreen: FC<ComputerScreenProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/envelop.tsx b/src/components/atoms/icons/envelop.tsx
index 7b50d1d..84dca97 100644
--- a/src/components/atoms/icons/envelop.tsx
+++ b/src/components/atoms/icons/envelop.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './envelop.module.scss';
 
 export type EnvelopProps = {
@@ -13,7 +13,7 @@ export type EnvelopProps = {
  *
  * Render an envelop svg icon.
  */
-const Envelop: VFC<EnvelopProps> = ({ className = '' }) => {
+const Envelop: FC<EnvelopProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/hamburger.tsx b/src/components/atoms/icons/hamburger.tsx
index 7e7c2c9..93aed2a 100644
--- a/src/components/atoms/icons/hamburger.tsx
+++ b/src/components/atoms/icons/hamburger.tsx
@@ -1,7 +1,7 @@
 import { FC } from 'react';
 import styles from './hamburger.module.scss';
 
-type HamburgerProps = {
+export type HamburgerProps = {
   /**
    * Set additional classnames to the icon wrapper.
    */
diff --git a/src/components/atoms/icons/home.tsx b/src/components/atoms/icons/home.tsx
index 71bbc4a..3b6732d 100644
--- a/src/components/atoms/icons/home.tsx
+++ b/src/components/atoms/icons/home.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './home.module.scss';
 
 export type HomeProps = {
@@ -13,7 +13,7 @@ export type HomeProps = {
  *
  * Render a home svg icon.
  */
-const Home: VFC<HomeProps> = ({ className = '' }) => {
+const Home: FC<HomeProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/magnifying-glass.tsx b/src/components/atoms/icons/magnifying-glass.tsx
index 445ef10..1ca2a44 100644
--- a/src/components/atoms/icons/magnifying-glass.tsx
+++ b/src/components/atoms/icons/magnifying-glass.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './magnifying-glass.module.scss';
 
 export type MagnifyingGlassProps = {
@@ -13,7 +13,7 @@ export type MagnifyingGlassProps = {
  *
  * Render a magnifying glass svg icon.
  */
-const MagnifyingGlass: VFC<MagnifyingGlassProps> = ({ className = '' }) => {
+const MagnifyingGlass: FC<MagnifyingGlassProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/moon.tsx b/src/components/atoms/icons/moon.tsx
index 4f52319..ec4fa0c 100644
--- a/src/components/atoms/icons/moon.tsx
+++ b/src/components/atoms/icons/moon.tsx
@@ -1,7 +1,7 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './moon.module.scss';
 
-type MoonProps = {
+export type MoonProps = {
   /**
    * Set additional classnames to the icon.
    */
@@ -12,7 +12,7 @@ type MoonProps = {
   title?: string;
 };
 
-const Moon: VFC<MoonProps> = ({ className = '', title }) => {
+const Moon: FC<MoonProps> = ({ className = '', title }) => {
   return (
     <svg
       className={`${styles.icon} ${className}`}
diff --git a/src/components/atoms/icons/plus-minus.tsx b/src/components/atoms/icons/plus-minus.tsx
index 78aa14a..e8897b7 100644
--- a/src/components/atoms/icons/plus-minus.tsx
+++ b/src/components/atoms/icons/plus-minus.tsx
@@ -1,7 +1,7 @@
 import { FC } from 'react';
 import styles from './plus-minus.module.scss';
 
-type PlusMinusProps = {
+export type PlusMinusProps = {
   /**
    * Set additional classnames to the icon.
    */
diff --git a/src/components/atoms/icons/posts-stack.tsx b/src/components/atoms/icons/posts-stack.tsx
index 1998d25..ab21323 100644
--- a/src/components/atoms/icons/posts-stack.tsx
+++ b/src/components/atoms/icons/posts-stack.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './posts-stack.module.scss';
 
 export type PostsStackProps = {
@@ -13,7 +13,7 @@ export type PostsStackProps = {
  *
  * Render a posts stack svg icon.
  */
-const PostsStack: VFC<PostsStackProps> = ({ className = '' }) => {
+const PostsStack: FC<PostsStackProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/sun.tsx b/src/components/atoms/icons/sun.tsx
index fa9d922..ca31747 100644
--- a/src/components/atoms/icons/sun.tsx
+++ b/src/components/atoms/icons/sun.tsx
@@ -1,7 +1,7 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './sun.module.scss';
 
-type SunProps = {
+export type SunProps = {
   /**
    * Set additional classnames to the icon.
    */
@@ -17,7 +17,7 @@ type SunProps = {
  *
  * Render a svg sun icon.
  */
-const Sun: VFC<SunProps> = ({ className = '', title }) => {
+const Sun: FC<SunProps> = ({ className = '', title }) => {
   return (
     <svg
       className={`${styles.sun} ${className}`}
diff --git a/src/components/atoms/images/logo.tsx b/src/components/atoms/images/logo.tsx
index 2e52110..3978882 100644
--- a/src/components/atoms/images/logo.tsx
+++ b/src/components/atoms/images/logo.tsx
@@ -1,7 +1,7 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './logo.module.scss';
 
-type LogoProps = {
+export type LogoProps = {
   /**
    * SVG Image title.
    */
@@ -13,7 +13,7 @@ type LogoProps = {
  *
  * Render a SVG logo.
  */
-const Logo: VFC<LogoProps> = ({ title }) => {
+const Logo: FC<LogoProps> = ({ title }) => {
   return (
     <svg
       viewBox="0 0 512 512"
diff --git a/src/components/atoms/layout/copyright.tsx b/src/components/atoms/layout/copyright.tsx
index 76252ee..f70695d 100644
--- a/src/components/atoms/layout/copyright.tsx
+++ b/src/components/atoms/layout/copyright.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './copyright.module.scss';
 
 export type CopyrightDates = {
@@ -32,7 +32,7 @@ export type CopyrightProps = {
  *
  * Renders a copyright information (owner, dates, license icon).
  */
-const Copyright: VFC<CopyrightProps> = ({ owner, dates, icon }) => {
+const Copyright: FC<CopyrightProps> = ({ owner, dates, icon }) => {
   const getFormattedDate = (date: string) => {
     const datetime = new Date(date).toISOString();
 
diff --git a/src/components/atoms/layout/main.tsx b/src/components/atoms/layout/main.tsx
index 4549328..d92a5c7 100644
--- a/src/components/atoms/layout/main.tsx
+++ b/src/components/atoms/layout/main.tsx
@@ -1,6 +1,10 @@
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 
 export type MainProps = {
+  /**
+   * The main body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the main element.
    */
diff --git a/src/components/atoms/layout/no-script.tsx b/src/components/atoms/layout/no-script.tsx
index 6358cf8..a503e0c 100644
--- a/src/components/atoms/layout/no-script.tsx
+++ b/src/components/atoms/layout/no-script.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './no-script.module.scss';
 
 export type NoScriptProps = {
@@ -12,7 +12,7 @@ export type NoScriptProps = {
   position?: 'initial' | 'top';
 };
 
-const NoScript: VFC<NoScriptProps> = ({ message, position = 'initial' }) => {
+const NoScript: FC<NoScriptProps> = ({ message, position = 'initial' }) => {
   const positionClass = styles[`noscript--${position}`];
 
   return <div className={`${styles.noscript} ${positionClass}`}>{message}</div>;
diff --git a/src/components/atoms/layout/notice.tsx b/src/components/atoms/layout/notice.tsx
index b6e09c5..115bd9c 100644
--- a/src/components/atoms/layout/notice.tsx
+++ b/src/components/atoms/layout/notice.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './notice.module.scss';
 
 export type NoticeKind = 'error' | 'info' | 'success' | 'warning';
@@ -19,7 +19,7 @@ export type NoticeProps = {
  *
  * Render a colored message depending on notice kind.
  */
-const Notice: VFC<NoticeProps> = ({ kind, message }) => {
+const Notice: FC<NoticeProps> = ({ kind, message }) => {
   const kindClass = `wrapper--${kind}`;
 
   return (
diff --git a/src/components/atoms/layout/section.tsx b/src/components/atoms/layout/section.tsx
index f1bbb34..cb727ff 100644
--- a/src/components/atoms/layout/section.tsx
+++ b/src/components/atoms/layout/section.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import Heading from '../headings/heading';
 import styles from './section.module.scss';
 
@@ -32,7 +32,7 @@ export type SectionProps = {
  *
  * Render a section element.
  */
-const Section: VFC<SectionProps> = ({
+const Section: FC<SectionProps> = ({
   className = '',
   content,
   title,
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
index e7ead86..d23667a 100644
--- a/src/components/atoms/links/link.module.scss
+++ b/src/components/atoms/links/link.module.scss
@@ -5,7 +5,9 @@
   &[hreflang] {
     &::after {
       display: inline-block;
-      content: "\0000a0["attr(hreflang) "]";
+      /* Prettier is removing spacing between content parts. */
+      /* prettier-ignore */
+      content: "\0000a0[" attr(hreflang) "]";
       font-size: var(--font-size-sm);
     }
   }
@@ -13,22 +15,30 @@
   &--external {
     &::after {
       display: inline-block;
-      content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      /* Prettier is removing spacing between content parts. */
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
     }
 
     &:focus:not(:active)::after {
-      content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      /* Prettier is removing spacing between content parts. */
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
     }
 
     &[hreflang] {
       &::after {
-        content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+        /* Prettier is removing spacing between content parts. */
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg(
             '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
           ));
       }
 
       &:focus:not(:active)::after {
-        content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+        /* Prettier is removing spacing between content parts. */
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg(
             '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
           ));
       }
diff --git a/src/components/atoms/links/link.tsx b/src/components/atoms/links/link.tsx
index 87f11fc..674c07b 100644
--- a/src/components/atoms/links/link.tsx
+++ b/src/components/atoms/links/link.tsx
@@ -1,8 +1,12 @@
 import NextLink from 'next/link';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './link.module.scss';
 
 export type LinkProps = {
+  /**
+   * The link body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the link.
    */
diff --git a/src/components/atoms/links/nav-link.tsx b/src/components/atoms/links/nav-link.tsx
index 25c0e7d..7c6fede 100644
--- a/src/components/atoms/links/nav-link.tsx
+++ b/src/components/atoms/links/nav-link.tsx
@@ -1,5 +1,5 @@
 import Link from 'next/link';
-import { VFC, ReactNode } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './nav-link.module.scss';
 
 export type NavLinkProps = {
@@ -22,7 +22,7 @@ export type NavLinkProps = {
  *
  * Render a navigation link.
  */
-const NavLink: VFC<NavLinkProps> = ({ href, label, logo }) => {
+const NavLink: FC<NavLinkProps> = ({ href, label, logo }) => {
   return (
     <Link href={href}>
       <a className={styles.link}>
diff --git a/src/components/atoms/links/sharing-link.tsx b/src/components/atoms/links/sharing-link.tsx
index 3cd2dd1..ca53ef9 100644
--- a/src/components/atoms/links/sharing-link.tsx
+++ b/src/components/atoms/links/sharing-link.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './sharing-link.module.scss';
 
@@ -26,7 +26,7 @@ export type SharingLinkProps = {
  *
  * Render a sharing link.
  */
-const SharingLink: VFC<SharingLinkProps> = ({ medium, url }) => {
+const SharingLink: FC<SharingLinkProps> = ({ medium, url }) => {
   const intl = useIntl();
   const text = intl.formatMessage(
     {
diff --git a/src/components/atoms/links/social-link.tsx b/src/components/atoms/links/social-link.tsx
index 8c7c790..464bc60 100644
--- a/src/components/atoms/links/social-link.tsx
+++ b/src/components/atoms/links/social-link.tsx
@@ -2,7 +2,7 @@ import GithubIcon from '@assets/images/social-media/github.svg';
 import GitlabIcon from '@assets/images/social-media/gitlab.svg';
 import LinkedInIcon from '@assets/images/social-media/linkedin.svg';
 import TwitterIcon from '@assets/images/social-media/twitter.svg';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './social-link.module.scss';
 
 export type SocialWebsite = 'Github' | 'Gitlab' | 'LinkedIn' | 'Twitter';
@@ -23,7 +23,7 @@ export type SocialLinkProps = {
  *
  * Render a social icon link.
  */
-const SocialLink: VFC<SocialLinkProps> = ({ name, url }) => {
+const SocialLink: FC<SocialLinkProps> = ({ name, url }) => {
   /**
    * Retrieve a social link icon by id.
    * @param {string} id - The social website id.
diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx
index 0a92465..a60a6a1 100644
--- a/src/components/atoms/lists/description-list.tsx
+++ b/src/components/atoms/lists/description-list.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './description-list.module.scss';
 
 export type DescriptionListItem = {
@@ -52,7 +52,7 @@ export type DescriptionListProps = {
  *
  * Render a description list.
  */
-const DescriptionList: VFC<DescriptionListProps> = ({
+const DescriptionList: FC<DescriptionListProps> = ({
   className = '',
   descriptionClassName = '',
   groupClassName = '',
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
index d100a31..6726802 100644
--- a/src/components/atoms/lists/list.tsx
+++ b/src/components/atoms/lists/list.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './list.module.scss';
 
 export type ListItem = {
@@ -44,7 +44,7 @@ export type ListProps = {
  *
  * Render either an ordered or an unordered list.
  */
-const List: VFC<ListProps> = ({
+const List: FC<ListProps> = ({
   className = '',
   items,
   itemsClassName = '',
diff --git a/src/components/atoms/loaders/progress-bar.tsx b/src/components/atoms/loaders/progress-bar.tsx
index 1b1ff06..9bac847 100644
--- a/src/components/atoms/loaders/progress-bar.tsx
+++ b/src/components/atoms/loaders/progress-bar.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './progress-bar.module.scss';
 
 export type ProgressBarProps = {
@@ -29,7 +29,7 @@ export type ProgressBarProps = {
  *
  * Render a progress bar.
  */
-const ProgressBar: VFC<ProgressBarProps> = ({
+const ProgressBar: FC<ProgressBarProps> = ({
   current,
   info,
   min,
diff --git a/src/components/atoms/loaders/spinner.tsx b/src/components/atoms/loaders/spinner.tsx
index bff0f25..6655141 100644
--- a/src/components/atoms/loaders/spinner.tsx
+++ b/src/components/atoms/loaders/spinner.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './spinner.module.scss';
 
@@ -14,7 +14,7 @@ export type SpinnerProps = {
  *
  * Render a loading message with animation.
  */
-const Spinner: VFC<SpinnerProps> = ({ message }) => {
+const Spinner: FC<SpinnerProps> = ({ message }) => {
   const intl = useIntl();
 
   return (
diff --git a/src/components/molecules/buttons/back-to-top.tsx b/src/components/molecules/buttons/back-to-top.tsx
index 8a52231..bd1925a 100644
--- a/src/components/molecules/buttons/back-to-top.tsx
+++ b/src/components/molecules/buttons/back-to-top.tsx
@@ -1,18 +1,16 @@
-import ButtonLink from '@components/atoms/buttons/button-link';
+import ButtonLink, {
+  type ButtonLinkProps,
+} from '@components/atoms/buttons/button-link';
 import Arrow from '@components/atoms/icons/arrow';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './back-to-top.module.scss';
 
-export type BackToTopProps = {
+export type BackToTopProps = Pick<ButtonLinkProps, 'target'> & {
   /**
    * Set additional classnames to the button wrapper.
    */
   className?: string;
-  /**
-   * An element id (without hashtag) to use as anchor.
-   */
-  target: string;
 };
 
 /**
@@ -20,7 +18,7 @@ export type BackToTopProps = {
  *
  * Render a back to top link.
  */
-const BackToTop: VFC<BackToTopProps> = ({ className = '', target }) => {
+const BackToTop: FC<BackToTopProps> = ({ className = '', target }) => {
   const intl = useIntl();
   const linkName = intl.formatMessage({
     defaultMessage: 'Back to top',
diff --git a/src/components/molecules/buttons/heading-button.tsx b/src/components/molecules/buttons/heading-button.tsx
index fc79749..0ed9a76 100644
--- a/src/components/molecules/buttons/heading-button.tsx
+++ b/src/components/molecules/buttons/heading-button.tsx
@@ -1,6 +1,6 @@
 import Heading, { type HeadingProps } from '@components/atoms/headings/heading';
 import PlusMinus from '@components/atoms/icons/plus-minus';
-import { SetStateAction, VFC } from 'react';
+import { FC, SetStateAction } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './heading-button.module.scss';
 
@@ -28,7 +28,7 @@ export type HeadingButtonProps = Pick<HeadingProps, 'level'> & {
  *
  * Render a button as accordion title to toggle body.
  */
-const HeadingButton: VFC<HeadingButtonProps> = ({
+const HeadingButton: FC<HeadingButtonProps> = ({
   className = '',
   expanded,
   level,
diff --git a/src/components/molecules/buttons/help-button.tsx b/src/components/molecules/buttons/help-button.tsx
index aeb84ec..f19322f 100644
--- a/src/components/molecules/buttons/help-button.tsx
+++ b/src/components/molecules/buttons/help-button.tsx
@@ -1,5 +1,5 @@
-import Button, { ButtonProps } from '@components/atoms/buttons/button';
-import { VFC } from 'react';
+import Button, { type ButtonProps } from '@components/atoms/buttons/button';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './help-button.module.scss';
 
@@ -15,7 +15,7 @@ export type HelpButtonProps = Pick<ButtonProps, 'onClick'> & {
  *
  * Render a button with an interrogation mark icon.
  */
-const HelpButton: VFC<HelpButtonProps> = ({ className = '', onClick }) => {
+const HelpButton: FC<HelpButtonProps> = ({ className = '', onClick }) => {
   const intl = useIntl();
   const text = intl.formatMessage({
     defaultMessage: 'Help',
diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx
index e1e6b2d..ec27922 100644
--- a/src/components/molecules/forms/ackee-select.test.tsx
+++ b/src/components/molecules/forms/ackee-select.test.tsx
@@ -1,5 +1,5 @@
-import userEvent from '@testing-library/user-event';
-import { render, screen } from '@test-utils';
+import user from '@testing-library/user-event';
+import { act, render, screen } from '@test-utils';
 import AckeeSelect from './ackee-select';
 
 describe('Select', () => {
@@ -9,13 +9,15 @@ describe('Select', () => {
     expect(screen.queryByRole('combobox')).not.toHaveValue('partial');
   });
 
-  it('should correctly change value when user choose another option', () => {
+  it('should correctly change value when user choose another option', async () => {
     render(<AckeeSelect initialValue="full" />);
 
-    userEvent.selectOptions(
-      screen.getByRole('combobox'),
-      screen.getByRole('option', { name: 'Partial' })
-    );
+    await act(async () => {
+      await user.selectOptions(
+        screen.getByRole('combobox'),
+        screen.getByRole('option', { name: 'Partial' })
+      );
+    });
 
     expect(screen.getByRole('combobox')).toHaveValue('partial');
     expect(screen.queryByRole('combobox')).not.toHaveValue('full');
diff --git a/src/components/molecules/forms/ackee-select.tsx b/src/components/molecules/forms/ackee-select.tsx
index 4a8410c..101e5b5 100644
--- a/src/components/molecules/forms/ackee-select.tsx
+++ b/src/components/molecules/forms/ackee-select.tsx
@@ -1,8 +1,8 @@
 import { SelectOptions } from '@components/atoms/forms/select';
-import { Dispatch, SetStateAction, useState, VFC } from 'react';
+import { Dispatch, FC, SetStateAction, useState } from 'react';
 import { useIntl } from 'react-intl';
 import SelectWithTooltip, {
-  SelectWithTooltipProps,
+  type SelectWithTooltipProps,
 } from './select-with-tooltip';
 
 export type AckeeOptions = 'full' | 'partial';
@@ -22,7 +22,7 @@ export type AckeeSelectProps = Pick<
  *
  * Render a select to set Ackee settings.
  */
-const AckeeSelect: VFC<AckeeSelectProps> = ({ initialValue, ...props }) => {
+const AckeeSelect: FC<AckeeSelectProps> = ({ initialValue, ...props }) => {
   const intl = useIntl();
   const [value, setValue] = useState<AckeeOptions>(initialValue);
 
diff --git a/src/components/molecules/forms/labelled-field.tsx b/src/components/molecules/forms/labelled-field.tsx
index 08d0126..ecc9255 100644
--- a/src/components/molecules/forms/labelled-field.tsx
+++ b/src/components/molecules/forms/labelled-field.tsx
@@ -1,6 +1,6 @@
 import Field, { type FieldProps } from '@components/atoms/forms/field';
 import Label from '@components/atoms/forms/label';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './labelled-field.module.scss';
 
 export type LabelledFieldProps = FieldProps & {
@@ -23,7 +23,7 @@ export type LabelledFieldProps = FieldProps & {
  *
  * Render a field tied to a label.
  */
-const LabelledField: VFC<LabelledFieldProps> = ({
+const LabelledField: FC<LabelledFieldProps> = ({
   hideLabel = false,
   id,
   label,
diff --git a/src/components/molecules/forms/labelled-select.tsx b/src/components/molecules/forms/labelled-select.tsx
index 7d4237a..23057d0 100644
--- a/src/components/molecules/forms/labelled-select.tsx
+++ b/src/components/molecules/forms/labelled-select.tsx
@@ -1,6 +1,6 @@
-import Label, { LabelProps } from '@components/atoms/forms/label';
+import Label, { type LabelProps } from '@components/atoms/forms/label';
 import Select, { type SelectProps } from '@components/atoms/forms/select';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './labelled-select.module.scss';
 
 export type LabelledSelectProps = Omit<
@@ -14,7 +14,7 @@ export type LabelledSelectProps = Omit<
   /**
    * Set additional classnames to the label.
    */
-  labelClassName?: string;
+  labelClassName?: LabelProps['className'];
   /**
    * The label position. Default: top.
    */
@@ -26,10 +26,15 @@ export type LabelledSelectProps = Omit<
   /**
    * Set additional classnames to the select field.
    */
-  selectClassName?: string;
+  selectClassName?: SelectProps['className'];
 };
 
-const LabelledSelect: VFC<LabelledSelectProps> = ({
+/**
+ * LabelledSelect component
+ *
+ * Render a select with a label.
+ */
+const LabelledSelect: FC<LabelledSelectProps> = ({
   id,
   label,
   labelClassName = '',
diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx
index 9f30b42..24b54ae 100644
--- a/src/components/molecules/forms/motion-toggle.tsx
+++ b/src/components/molecules/forms/motion-toggle.tsx
@@ -1,8 +1,8 @@
 import Toggle, {
-  ToggleChoices,
-  ToggleProps,
+  type ToggleChoices,
+  type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 
 export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
@@ -12,7 +12,7 @@ export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
  *
  * Render a Toggle component to set reduce motion.
  */
-const MotionToggle: VFC<MotionToggleProps> = ({ value, ...props }) => {
+const MotionToggle: FC<MotionToggleProps> = ({ value, ...props }) => {
   const intl = useIntl();
   const [isDeactivated, setIsDeactivated] = useState<boolean>(value);
   const reduceMotionLabel = intl.formatMessage({
diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx
index daee6bd..0b9c447 100644
--- a/src/components/molecules/forms/prism-theme-toggle.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.tsx
@@ -1,10 +1,10 @@
 import Moon from '@components/atoms/icons/moon';
 import Sun from '@components/atoms/icons/sun';
 import Toggle, {
-  ToggleChoices,
-  ToggleProps,
+  type ToggleChoices,
+  type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 
 export type PrismThemeToggleProps = Pick<
@@ -17,7 +17,7 @@ export type PrismThemeToggleProps = Pick<
  *
  * Render a Toggle component to set code blocks theme.
  */
-const PrismThemeToggle: VFC<PrismThemeToggleProps> = ({ value, ...props }) => {
+const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ value, ...props }) => {
   const intl = useIntl();
   const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
   const themeLabel = intl.formatMessage({
diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx
index f537e1e..cf7b041 100644
--- a/src/components/molecules/forms/select-with-tooltip.tsx
+++ b/src/components/molecules/forms/select-with-tooltip.tsx
@@ -1,4 +1,4 @@
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import HelpButton from '../buttons/help-button';
 import Tooltip, { type TooltipProps } from '../modals/tooltip';
 import LabelledSelect, { type LabelledSelectProps } from './labelled-select';
@@ -9,14 +9,10 @@ export type SelectWithTooltipProps = Omit<
   'labelPosition'
 > &
   Pick<TooltipProps, 'title' | 'content'> & {
-    /**
-     * The select label.
-     */
-    label: string;
     /**
      * Set additional classnames to the tooltip wrapper.
      */
-    tooltipClassName?: string;
+    tooltipClassName?: TooltipProps['className'];
   };
 
 /**
@@ -24,7 +20,7 @@ export type SelectWithTooltipProps = Omit<
  *
  * Render a select with a button to display a tooltip about options.
  */
-const SelectWithTooltip: VFC<SelectWithTooltipProps> = ({
+const SelectWithTooltip: FC<SelectWithTooltipProps> = ({
   title,
   content,
   id,
diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx
index eb56ce9..10c6c47 100644
--- a/src/components/molecules/forms/theme-toggle.tsx
+++ b/src/components/molecules/forms/theme-toggle.tsx
@@ -1,10 +1,10 @@
 import Moon from '@components/atoms/icons/moon';
 import Sun from '@components/atoms/icons/sun';
 import Toggle, {
-  ToggleChoices,
-  ToggleProps,
+  type ToggleChoices,
+  type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 
 export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
@@ -14,7 +14,7 @@ export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
  *
  * Render a Toggle component to set theme.
  */
-const ThemeToggle: VFC<ThemeToggleProps> = ({ value, ...props }) => {
+const ThemeToggle: FC<ThemeToggleProps> = ({ value, ...props }) => {
   const intl = useIntl();
   const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
   const themeLabel = intl.formatMessage({
diff --git a/src/components/molecules/forms/toggle.tsx b/src/components/molecules/forms/toggle.tsx
index dff2d2d..288062d 100644
--- a/src/components/molecules/forms/toggle.tsx
+++ b/src/components/molecules/forms/toggle.tsx
@@ -1,6 +1,6 @@
-import Checkbox from '@components/atoms/forms/checkbox';
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label, { type LabelProps } from '@components/atoms/forms/label';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './toggle.module.scss';
 
 export type ToggleChoices = {
@@ -14,15 +14,11 @@ export type ToggleChoices = {
   right: ReactNode;
 };
 
-export type ToggleProps = {
+export type ToggleProps = Pick<CheckboxProps, 'id' | 'name'> & {
   /**
    * The toggle choices.
    */
   choices: ToggleChoices;
-  /**
-   * The input id.
-   */
-  id: string;
   /**
    * The toggle label.
    */
@@ -30,15 +26,11 @@ export type ToggleProps = {
   /**
    * Set additional classnames to the label.
    */
-  labelClassName?: string;
+  labelClassName?: LabelProps['className'];
   /**
    * The label size.
    */
   labelSize?: LabelProps['size'];
-  /**
-   * The input name.
-   */
-  name: string;
   /**
    * The toggle value. True if checked.
    */
@@ -54,7 +46,7 @@ export type ToggleProps = {
  *
  * Render a toggle with a label and two choices.
  */
-const Toggle: VFC<ToggleProps> = ({
+const Toggle: FC<ToggleProps> = ({
   choices,
   id,
   label,
diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx
index 1d8787e..31cbcd1 100644
--- a/src/components/molecules/images/responsive-image.tsx
+++ b/src/components/molecules/images/responsive-image.tsx
@@ -1,6 +1,6 @@
-import Link from '@components/atoms/links/link';
-import Image, { ImageProps } from 'next/image';
-import { VFC } from 'react';
+import Link, { type LinkProps } from '@components/atoms/links/link';
+import Image, { type ImageProps } from 'next/image';
+import { FC } from 'react';
 import styles from './responsive-image.module.scss';
 
 export type ResponsiveImageProps = Omit<
@@ -26,7 +26,7 @@ export type ResponsiveImageProps = Omit<
   /**
    * A link target.
    */
-  target?: string;
+  target?: LinkProps['href'];
   /**
    * The image width.
    */
@@ -38,7 +38,7 @@ export type ResponsiveImageProps = Omit<
  *
  * Render a responsive image wrapped in a figure element.
  */
-const ResponsiveImage: VFC<ResponsiveImageProps> = ({
+const ResponsiveImage: FC<ResponsiveImageProps> = ({
   alt,
   caption,
   className = '',
diff --git a/src/components/molecules/layout/branding.tsx b/src/components/molecules/layout/branding.tsx
index 9f564bf..9fe89e7 100644
--- a/src/components/molecules/layout/branding.tsx
+++ b/src/components/molecules/layout/branding.tsx
@@ -1,11 +1,11 @@
 import Heading from '@components/atoms/headings/heading';
 import Link from 'next/link';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './branding.module.scss';
-import FlippingLogo from './flipping-logo';
+import FlippingLogo, { type FlippingLogoProps } from './flipping-logo';
 
-type BrandingProps = {
+export type BrandingProps = Pick<FlippingLogoProps, 'photo'> & {
   /**
    * The Branding baseline.
    */
@@ -14,10 +14,6 @@ type BrandingProps = {
    * Use H1 if the current page is homepage. Default: false.
    */
   isHome?: boolean;
-  /**
-   * A photography URL.
-   */
-  photo: string;
   /**
    * The Branding title;
    */
@@ -33,7 +29,7 @@ type BrandingProps = {
  *
  * Render the branding logo, title and optional baseline.
  */
-const Branding: VFC<BrandingProps> = ({
+const Branding: FC<BrandingProps> = ({
   baseline,
   isHome = false,
   photo,
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index 23a0e54..89f100e 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -1,11 +1,11 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import DescriptionList, {
-  DescriptionListItem,
+  type DescriptionListItem,
 } from '@components/atoms/lists/description-list';
-import { VFC } from 'react';
+import { FC } from 'react';
 import ResponsiveImage, {
-  ResponsiveImageProps,
+  type ResponsiveImageProps,
 } from '../images/responsive-image';
 import styles from './card.module.scss';
 
@@ -68,7 +68,7 @@ export type CardProps = {
  *
  * Render a link with minimal information about its content.
  */
-const Card: VFC<CardProps> = ({
+const Card: FC<CardProps> = ({
   className = '',
   cover,
   coverFit = 'cover',
diff --git a/src/components/molecules/layout/flipping-logo.tsx b/src/components/molecules/layout/flipping-logo.tsx
index 6f7645f..4a216ef 100644
--- a/src/components/molecules/layout/flipping-logo.tsx
+++ b/src/components/molecules/layout/flipping-logo.tsx
@@ -1,9 +1,9 @@
-import Logo from '@components/atoms/images/logo';
+import Logo, { type LogoProps } from '@components/atoms/images/logo';
 import Image from 'next/image';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './flipping-logo.module.scss';
 
-type FlippingLogoProps = {
+export type FlippingLogoProps = {
   /**
    * Set additional classnames to the logo wrapper.
    */
@@ -15,7 +15,7 @@ type FlippingLogoProps = {
   /**
    * Logo image title.
    */
-  logoTitle?: string;
+  logoTitle?: LogoProps['title'];
   /**
    * Photo url.
    */
@@ -27,7 +27,7 @@ type FlippingLogoProps = {
  *
  * Render a logo and a photo with a flipping effect.
  */
-const FlippingLogo: VFC<FlippingLogoProps> = ({
+const FlippingLogo: FC<FlippingLogoProps> = ({
   className = '',
   altText,
   logoTitle,
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index 218ebd9..fcce473 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -2,7 +2,7 @@ import DescriptionList, {
   type DescriptionListProps,
   type DescriptionListItem,
 } from '@components/atoms/lists/description-list';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 
 export type MetaItem = {
   /**
@@ -43,7 +43,7 @@ export type MetaProps = {
  *
  * Renders the page metadata.
  */
-const Meta: VFC<MetaProps> = ({ data, ...props }) => {
+const Meta: FC<MetaProps> = ({ data, ...props }) => {
   /**
    * Transform the metadata to description list item format.
    *
diff --git a/src/components/molecules/layout/widget.tsx b/src/components/molecules/layout/widget.tsx
index c04362a..feb2add 100644
--- a/src/components/molecules/layout/widget.tsx
+++ b/src/components/molecules/layout/widget.tsx
@@ -1,11 +1,17 @@
-import { FC, useState } from 'react';
-import HeadingButton, { HeadingButtonProps } from '../buttons/heading-button';
+import { FC, ReactNode, useState } from 'react';
+import HeadingButton, {
+  type HeadingButtonProps,
+} from '../buttons/heading-button';
 import styles from './widget.module.scss';
 
 export type WidgetProps = Pick<
   HeadingButtonProps,
   'expanded' | 'level' | 'title'
 > & {
+  /**
+   * The widget body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the widget wrapper.
    */
diff --git a/src/components/molecules/modals/modal.test.tsx b/src/components/molecules/modals/modal.test.tsx
index 14fb224..9a0e237 100644
--- a/src/components/molecules/modals/modal.test.tsx
+++ b/src/components/molecules/modals/modal.test.tsx
@@ -1,9 +1,18 @@
 import { render, screen } from '@test-utils';
 import Modal from './modal';
 
+const title = 'A custom title';
+const children =
+  'Labore ullam delectus sit modi quam dolores. Ratione id sint aliquid facilis ipsum. Unde necessitatibus provident minus.';
+
 describe('Modal', () => {
   it('renders a title', () => {
-    render(<Modal title="A custom title" />);
-    expect(screen.getByText('A custom title')).toBeInTheDocument();
+    render(<Modal title={title}>{children}</Modal>);
+    expect(screen.getByText(title)).toBeInTheDocument();
+  });
+
+  it('renders the modal body', () => {
+    render(<Modal title={title}>{children}</Modal>);
+    expect(screen.getByText(children)).toBeInTheDocument();
   });
 });
diff --git a/src/components/molecules/modals/modal.tsx b/src/components/molecules/modals/modal.tsx
index 52ada57..58f5fa0 100644
--- a/src/components/molecules/modals/modal.tsx
+++ b/src/components/molecules/modals/modal.tsx
@@ -1,13 +1,17 @@
-import Heading from '@components/atoms/headings/heading';
-import { CogProps } from '@components/atoms/icons/cog';
-import { MagnifyingGlassProps } from '@components/atoms/icons/magnifying-glass';
+import Heading, { type HeadingProps } from '@components/atoms/headings/heading';
+import { type CogProps } from '@components/atoms/icons/cog';
+import { type MagnifyingGlassProps } from '@components/atoms/icons/magnifying-glass';
 import dynamic from 'next/dynamic';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './modal.module.scss';
 
 export type Icons = 'cogs' | 'search';
 
 export type ModalProps = {
+  /**
+   * The modal body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames.
    */
@@ -15,7 +19,7 @@ export type ModalProps = {
   /**
    * Set additional classnames to the heading.
    */
-  headingClassName?: string;
+  headingClassName?: HeadingProps['className'];
   /**
    * A icon to illustrate the modal.
    */
diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx
index 73f36e7..80721f3 100644
--- a/src/components/molecules/modals/tooltip.tsx
+++ b/src/components/molecules/modals/tooltip.tsx
@@ -1,5 +1,5 @@
 import List, { type ListItem } from '@components/atoms/lists/list';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './tooltip.module.scss';
 
 export type TooltipProps = {
@@ -26,7 +26,7 @@ export type TooltipProps = {
  *
  * Render a tooltip modal.
  */
-const Tooltip: VFC<TooltipProps> = ({
+const Tooltip: FC<TooltipProps> = ({
   className = '',
   content,
   icon,
diff --git a/src/components/molecules/nav/breadcrumb.tsx b/src/components/molecules/nav/breadcrumb.tsx
index 33af735..6dc86a0 100644
--- a/src/components/molecules/nav/breadcrumb.tsx
+++ b/src/components/molecules/nav/breadcrumb.tsx
@@ -1,7 +1,7 @@
 import Link from '@components/atoms/links/link';
 import { settings } from '@utils/config';
 import Script from 'next/script';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import { BreadcrumbList, ListItem, WithContext } from 'schema-dts';
 import styles from './breadcrumb.module.scss';
@@ -37,7 +37,7 @@ export type BreadcrumbProps = {
  *
  * Render a breadcrumb navigation.
  */
-const Breadcrumb: VFC<BreadcrumbProps> = ({ items, ...props }) => {
+const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
   const intl = useIntl();
 
   /**
diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx
index 6ef9158..2666ea2 100644
--- a/src/components/molecules/nav/nav.tsx
+++ b/src/components/molecules/nav/nav.tsx
@@ -1,6 +1,6 @@
 import Link from '@components/atoms/links/link';
 import NavLink from '@components/atoms/links/nav-link';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './nav.module.scss';
 
 export type NavItem = {
@@ -46,7 +46,7 @@ export type NavProps = {
  *
  * Render the nav links.
  */
-const Nav: VFC<NavProps> = ({
+const Nav: FC<NavProps> = ({
   className = '',
   items,
   kind,
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
index 1ab7cf2..670176c 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -1,4 +1,3 @@
-import Notice from '@components/atoms/layout/notice';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import { IntlProvider } from 'react-intl';
 import CommentFormComponent from './comment-form';
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index 6acbf94..d7cb0f5 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -3,7 +3,7 @@ import Form from '@components/atoms/forms/form';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
-import { ReactNode, useState, VFC } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './comment-form.module.scss';
 
@@ -31,7 +31,7 @@ export type CommentFormProps = {
   titleLevel?: HeadingLevel;
 };
 
-const CommentForm: VFC<CommentFormProps> = ({
+const CommentForm: FC<CommentFormProps> = ({
   className = '',
   Notice,
   saveComment,
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
index 994244a..4a6902b 100644
--- a/src/components/organisms/forms/contact-form.tsx
+++ b/src/components/organisms/forms/contact-form.tsx
@@ -2,7 +2,7 @@ import Button from '@components/atoms/buttons/button';
 import Form from '@components/atoms/forms/form';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
-import { ReactNode, useState, VFC } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './contact-form.module.scss';
 
@@ -27,7 +27,7 @@ export type ContactFormProps = {
  *
  * Render a contact form.
  */
-const ContactForm: VFC<ContactFormProps> = ({
+const ContactForm: FC<ContactFormProps> = ({
   className = '',
   Notice,
   sendMail,
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form.tsx
index 351d93c..18b7c08 100644
--- a/src/components/organisms/forms/search-form.tsx
+++ b/src/components/organisms/forms/search-form.tsx
@@ -2,15 +2,20 @@ import Button from '@components/atoms/buttons/button';
 import Form from '@components/atoms/forms/form';
 import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
 import LabelledField, {
-  LabelledFieldProps,
+  type LabelledFieldProps,
 } from '@components/molecules/forms/labelled-field';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './search-form.module.scss';
 
 export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'>;
 
-const SearchForm: VFC<SearchFormProps> = ({ hideLabel }) => {
+/**
+ * SearchForm component
+ *
+ * Render a search form.
+ */
+const SearchForm: FC<SearchFormProps> = ({ hideLabel }) => {
   const intl = useIntl();
   const fieldLabel = intl.formatMessage({
     defaultMessage: 'Search for:',
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index a53df0d..33ffe23 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -3,7 +3,7 @@ import List, {
   type ListProps,
 } from '@components/atoms/lists/list';
 import Card, { type CardProps } from '@components/molecules/layout/card';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './cards-list.module.scss';
 
 export type CardsListItem = Omit<
@@ -37,7 +37,7 @@ export type CardsListProps = {
  *
  * Return a list of Card components.
  */
-const CardsList: VFC<CardsListProps> = ({
+const CardsList: FC<CardsListProps> = ({
   coverFit,
   items,
   kind = 'unordered',
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
index c9cb067..15bfa24 100644
--- a/src/components/organisms/layout/footer.tsx
+++ b/src/components/organisms/layout/footer.tsx
@@ -1,7 +1,9 @@
-import Copyright, { CopyrightProps } from '@components/atoms/layout/copyright';
+import Copyright, {
+  type CopyrightProps,
+} from '@components/atoms/layout/copyright';
 import BackToTop from '@components/molecules/buttons/back-to-top';
 import Nav, { type NavItem } from '@components/molecules/nav/nav';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './footer.module.scss';
 
 export type FooterProps = {
@@ -28,12 +30,7 @@ export type FooterProps = {
  *
  * Renders a footer with copyright and nav;
  */
-const Footer: VFC<FooterProps> = ({
-  className,
-  copyright,
-  navItems,
-  topId,
-}) => {
+const Footer: FC<FooterProps> = ({ className, copyright, navItems, topId }) => {
   return (
     <footer className={`${styles.wrapper} ${className}`}>
       <Copyright
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
index 3f83342..42d0431 100644
--- a/src/components/organisms/layout/overview.tsx
+++ b/src/components/organisms/layout/overview.tsx
@@ -2,7 +2,7 @@ import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
 import Meta, { type MetaMap } from '@components/molecules/layout/meta';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './overview.module.scss';
 
 export type OverviewProps = {
@@ -15,7 +15,7 @@ export type OverviewProps = {
  *
  * Render an overview.
  */
-const Overview: VFC<OverviewProps> = ({ cover, meta }) => {
+const Overview: FC<OverviewProps> = ({ cover, meta }) => {
   return (
     <div className={styles.wrapper}>
       {cover && (
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 5214d70..b33acde 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -103,7 +103,7 @@ Summary.args = {
   cover: {
     alt: 'A cover',
     height: 480,
-    url: 'http://placeimg.com/640/480',
+    src: 'http://placeimg.com/640/480',
     width: 640,
   },
   excerpt:
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
index ce87c0c..4944805 100644
--- a/src/components/organisms/layout/summary.test.tsx
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -4,7 +4,7 @@ import Summary from './summary';
 const cover = {
   alt: 'A cover',
   height: 480,
-  url: 'http://placeimg.com/640/480',
+  src: 'http://placeimg.com/640/480',
   width: 640,
 };
 
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 3624e5d..733a660 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -2,18 +2,18 @@ import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import Arrow from '@components/atoms/icons/arrow';
 import Link from '@components/atoms/links/link';
-import ResponsiveImage from '@components/molecules/images/responsive-image';
+import ResponsiveImage, {
+  type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
 import Meta, { type MetaItem } from '@components/molecules/layout/meta';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './summary.module.scss';
 
-export type Cover = {
-  alt: string;
-  height: number;
-  url: string;
-  width: number;
-};
+export type Cover = Pick<
+  ResponsiveImageProps,
+  'alt' | 'src' | 'width' | 'height'
+>;
 
 export type RequiredMetaKey = 'publication';
 
@@ -35,11 +35,29 @@ export type OptionalMeta = {
 export type Meta = RequiredMeta & OptionalMeta;
 
 export type SummaryProps = {
+  /**
+   * The post cover.
+   */
   cover?: Cover;
+  /**
+   * The post excerpt.
+   */
   excerpt: string;
+  /**
+   * The post meta.
+   */
   meta: Meta;
+  /**
+   * The post title.
+   */
   title: string;
+  /**
+   * The heading level (hn).
+   */
   titleLevel?: HeadingLevel;
+  /**
+   * The post url.
+   */
   url: string;
 };
 
@@ -48,7 +66,7 @@ export type SummaryProps = {
  *
  * Render a page summary.
  */
-const Summary: VFC<SummaryProps> = ({
+const Summary: FC<SummaryProps> = ({
   cover,
   excerpt,
   meta,
@@ -57,18 +75,23 @@ const Summary: VFC<SummaryProps> = ({
   url,
 }) => {
   const intl = useIntl();
+  const readMore = intl.formatMessage(
+    {
+      defaultMessage: 'Read more<a11y> about {title}</a11y>',
+      description: 'Summary: read more link',
+      id: 'Zpgv+f',
+    },
+    {
+      title,
+      a11y: (chunks: string) => (
+        <span className="screen-reader-text">{chunks}</span>
+      ),
+    }
+  );
 
   return (
     <article className={styles.wrapper}>
-      {cover && (
-        <ResponsiveImage
-          alt={cover.alt}
-          src={cover.url}
-          width={cover.width}
-          height={cover.height}
-          className={styles.cover}
-        />
-      )}
+      {cover && <ResponsiveImage className={styles.cover} {...cover} />}
       <header className={styles.header}>
         <Link href={url}>
           <Heading level={titleLevel} className={styles.title}>
@@ -79,20 +102,10 @@ const Summary: VFC<SummaryProps> = ({
       <div className={styles.body}>
         {excerpt}
         <ButtonLink target={url} className={styles['read-more']}>
-          {intl.formatMessage(
-            {
-              defaultMessage: 'Read more<a11y> about {title}</a11y>',
-              description: 'Summary: read more link',
-              id: 'Zpgv+f',
-            },
-            {
-              title,
-              a11y: (chunks: string) => (
-                <span className="screen-reader-text">{chunks}</span>
-              ),
-            }
-          )}
-          <Arrow direction="right" />
+          <>
+            {readMore}
+            <Arrow direction="right" />
+          </>
         </ButtonLink>
       </div>
       <footer className={styles.footer}>
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
index 883b783..0e0ceed 100644
--- a/src/components/organisms/modals/search-modal.tsx
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -1,5 +1,5 @@
-import Modal from '@components/molecules/modals/modal';
-import { VFC } from 'react';
+import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import SearchForm from '../forms/search-form';
 import styles from './search-modal.module.scss';
@@ -8,7 +8,7 @@ export type SearchModalProps = {
   /**
    * Set additional classnames to modal wrapper.
    */
-  className?: string;
+  className?: ModalProps['className'];
 };
 
 /**
@@ -16,7 +16,7 @@ export type SearchModalProps = {
  *
  * Render a search form modal.
  */
-const SearchModal: VFC<SearchModalProps> = ({ className }) => {
+const SearchModal: FC<SearchModalProps> = ({ className }) => {
   const intl = useIntl();
   const modalTitle = intl.formatMessage({
     defaultMessage: 'Search',
diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx
index 25d6f6f..20d2605 100644
--- a/src/components/organisms/modals/settings-modal.tsx
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -1,10 +1,12 @@
 import Form from '@components/atoms/forms/form';
-import AckeeSelect from '@components/molecules/forms/ackee-select';
+import AckeeSelect, {
+  type AckeeSelectProps,
+} from '@components/molecules/forms/ackee-select';
 import MotionToggle from '@components/molecules/forms/motion-toggle';
 import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
 import ThemeToggle from '@components/molecules/forms/theme-toggle';
-import Modal from '@components/molecules/modals/modal';
-import { VFC } from 'react';
+import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './settings-modal.module.scss';
 
@@ -12,11 +14,11 @@ export type SettingsModalProps = {
   /**
    * Set additional classnames to the modal wrapper.
    */
-  className?: string;
+  className?: ModalProps['className'];
   /**
    * Set additional classnames to the tooltip wrapper.
    */
-  tooltipClassName?: string;
+  tooltipClassName?: AckeeSelectProps['tooltipClassName'];
 };
 
 /**
@@ -24,7 +26,7 @@ export type SettingsModalProps = {
  *
  * Render a modal with settings options.
  */
-const SettingsModal: VFC<SettingsModalProps> = ({
+const SettingsModal: FC<SettingsModalProps> = ({
   className = '',
   tooltipClassName = '',
 }) => {
diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx
index 50bbe8b..35e3fd6 100644
--- a/src/components/organisms/toolbar/main-nav.tsx
+++ b/src/components/organisms/toolbar/main-nav.tsx
@@ -1,17 +1,20 @@
 import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label from '@components/atoms/forms/label';
 import Hamburger from '@components/atoms/icons/hamburger';
-import Nav, { type NavItem } from '@components/molecules/nav/nav';
-import { VFC } from 'react';
+import Nav, {
+  type NavProps,
+  type NavItem,
+} from '@components/molecules/nav/nav';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
-import sharedStyles from './toolbar-items.module.scss';
 import mainNavStyles from './main-nav.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
 
 export type MainNavProps = {
   /**
    * Set additional classnames to the nav element.
    */
-  className?: string;
+  className?: NavProps['className'];
   /**
    * The button state.
    */
@@ -26,7 +29,12 @@ export type MainNavProps = {
   setIsActive: CheckboxProps['setValue'];
 };
 
-const MainNav: VFC<MainNavProps> = ({
+/**
+ * MainNav component
+ *
+ * Render the main navigation.
+ */
+const MainNav: FC<MainNavProps> = ({
   className = '',
   isActive,
   items,
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
index 070bce0..72cd576 100644
--- a/src/components/organisms/toolbar/search.tsx
+++ b/src/components/organisms/toolbar/search.tsx
@@ -1,17 +1,17 @@
-import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label from '@components/atoms/forms/label';
 import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
-import SearchModal from '../modals/search-modal';
-import sharedStyles from './toolbar-items.module.scss';
+import SearchModal, { type SearchModalProps } from '../modals/search-modal';
 import searchStyles from './search.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
 
 export type SearchProps = {
   /**
    * Set additional classnames to the modal wrapper.
    */
-  className?: string;
+  className?: SearchModalProps['className'];
   /**
    * The button state.
    */
@@ -22,11 +22,7 @@ export type SearchProps = {
   setIsActive: CheckboxProps['setValue'];
 };
 
-const Search: VFC<SearchProps> = ({
-  className = '',
-  isActive,
-  setIsActive,
-}) => {
+const Search: FC<SearchProps> = ({ className = '', isActive, setIsActive }) => {
   const intl = useIntl();
   const label = isActive
     ? intl.formatMessage({
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
index 88539fb..3b10226 100644
--- a/src/components/organisms/toolbar/settings.tsx
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -1,11 +1,13 @@
-import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label from '@components/atoms/forms/label';
 import Cog from '@components/atoms/icons/cog';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
-import SettingsModal from '../modals/settings-modal';
-import sharedStyles from './toolbar-items.module.scss';
+import SettingsModal, {
+  type SettingsModalProps,
+} from '../modals/settings-modal';
 import settingsStyles from './settings.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
 
 export type SettingsProps = {
   /**
@@ -23,10 +25,10 @@ export type SettingsProps = {
   /**
    * Set additional classnames to the tooltip wrapper.
    */
-  tooltipClassName?: string;
+  tooltipClassName?: SettingsModalProps['tooltipClassName'];
 };
 
-const Settings: VFC<SettingsProps> = ({
+const Settings: FC<SettingsProps> = ({
   className = '',
   isActive,
   setIsActive,
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
index 81e80cf..f1ce530 100644
--- a/src/components/organisms/toolbar/toolbar.tsx
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -1,4 +1,4 @@
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import MainNav, { type MainNavProps } from '../toolbar/main-nav';
 import Search from '../toolbar/search';
 import Settings from '../toolbar/settings';
@@ -20,7 +20,7 @@ export type ToolbarProps = {
  *
  * Render the website toolbar.
  */
-const Toolbar: VFC<ToolbarProps> = ({ className = '', nav }) => {
+const Toolbar: FC<ToolbarProps> = ({ className = '', nav }) => {
   const [isNavOpened, setIsNavOpened] = useState<boolean>(false);
   const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
   const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false);
diff --git a/src/components/organisms/widgets/image-widget.tsx b/src/components/organisms/widgets/image-widget.tsx
index 928d5ea..ba04c6a 100644
--- a/src/components/organisms/widgets/image-widget.tsx
+++ b/src/components/organisms/widgets/image-widget.tsx
@@ -1,28 +1,16 @@
-import ResponsiveImage from '@components/molecules/images/responsive-image';
+import ResponsiveImage, {
+  type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
 import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './image-widget.module.scss';
 
 export type Alignment = 'left' | 'center' | 'right';
 
-export type Image = {
-  /**
-   * An alternative text for the image.
-   */
-  alt: string;
-  /**
-   * The image height.
-   */
-  height: number;
-  /**
-   * The image source.
-   */
-  src: string;
-  /**
-   * The image width.
-   */
-  width: number;
-};
+export type Image = Pick<
+  ResponsiveImageProps,
+  'alt' | 'height' | 'src' | 'width'
+>;
 
 export type ImageWidgetProps = Pick<
   WidgetProps,
@@ -35,7 +23,7 @@ export type ImageWidgetProps = Pick<
   /**
    * Add a caption to the image.
    */
-  description?: string;
+  description?: ResponsiveImageProps['caption'];
   /**
    * An object describing the image.
    */
@@ -43,7 +31,7 @@ export type ImageWidgetProps = Pick<
   /**
    * Add a link to the image.
    */
-  url?: string;
+  url?: ResponsiveImageProps['target'];
 };
 
 /**
@@ -51,7 +39,7 @@ export type ImageWidgetProps = Pick<
  *
  * Renders a widget that print an image and an optional text.
  */
-const ImageWidget: VFC<ImageWidgetProps> = ({
+const ImageWidget: FC<ImageWidgetProps> = ({
   alignment = 'left',
   description,
   img,
diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
index 155354e..559d0b6 100644
--- a/src/components/organisms/widgets/links-list-widget.tsx
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -1,8 +1,11 @@
 import Link from '@components/atoms/links/link';
-import List, { ListProps, type ListItem } from '@components/atoms/lists/list';
+import List, {
+  type ListProps,
+  type ListItem,
+} from '@components/atoms/lists/list';
 import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
 import { slugify } from '@utils/helpers/slugify';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './links-list-widget.module.scss';
 
 export type LinksListItems = {
@@ -33,7 +36,7 @@ export type LinksListWidgetProps = Pick<WidgetProps, 'level' | 'title'> &
  *
  * Render a list of links inside a widget.
  */
-const LinksListWidget: VFC<LinksListWidgetProps> = ({
+const LinksListWidget: FC<LinksListWidgetProps> = ({
   items,
   kind = 'unordered',
   ...props
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
index ccd3a21..571c1a4 100644
--- a/src/components/organisms/widgets/sharing.tsx
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -2,7 +2,7 @@ import SharingLink, {
   type SharingMedium,
 } from '@components/atoms/links/sharing-link';
 import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './sharing.module.scss';
 
@@ -21,7 +21,7 @@ export type SharingData = {
   url: string;
 };
 
-export type SharingProps = WidgetProps & {
+export type SharingProps = Omit<WidgetProps, 'children'> & {
   /**
    * The page data to share.
    */
@@ -37,7 +37,7 @@ export type SharingProps = WidgetProps & {
  *
  * Render a list of sharing links inside a widget.
  */
-const Sharing: VFC<SharingProps> = ({ data, media, ...props }) => {
+const Sharing: FC<SharingProps> = ({ data, media, ...props }) => {
   const intl = useIntl();
 
   /**
-- 
cgit v1.2.3


From c95cce04393080a52a54191cff7be8fec68af4b0 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sun, 15 May 2022 17:45:41 +0200
Subject: chore: add Article pages

---
 src/components/atoms/layout/notice.module.scss     |   1 -
 .../organisms/forms/comment-form.stories.tsx       |  30 +--
 .../organisms/forms/comment-form.test.tsx          |   7 +-
 src/components/organisms/forms/comment-form.tsx    |  19 +-
 .../organisms/layout/comment.stories.tsx           |  39 +++-
 src/components/organisms/layout/comment.test.tsx   |   6 +-
 src/components/organisms/layout/comment.tsx        |  14 +-
 .../organisms/layout/comments-list.stories.tsx     |  30 ++-
 .../organisms/layout/comments-list.test.tsx        |   6 +-
 src/components/organisms/layout/comments-list.tsx  |  10 +-
 .../templates/page/page-layout.stories.tsx         |   6 +-
 src/components/templates/page/page-layout.tsx      |  95 +++++++-
 src/pages/article/[slug].tsx                       | 251 +++++++++++++++++++++
 src/services/graphql/api.ts                        |  52 ++++-
 src/services/graphql/comments.mutation.ts          |  30 +++
 src/services/graphql/comments.ts                   | 102 +++++++++
 16 files changed, 634 insertions(+), 64 deletions(-)
 create mode 100644 src/pages/article/[slug].tsx
 create mode 100644 src/services/graphql/comments.mutation.ts
 create mode 100644 src/services/graphql/comments.ts

(limited to 'src/components/organisms/forms/comment-form.tsx')

diff --git a/src/components/atoms/layout/notice.module.scss b/src/components/atoms/layout/notice.module.scss
index 38ec7ee..7fd972c 100644
--- a/src/components/atoms/layout/notice.module.scss
+++ b/src/components/atoms/layout/notice.module.scss
@@ -1,7 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 
 .wrapper {
-  width: max-content;
   padding: var(--spacing-2xs) var(--spacing-xs);
   border: fun.convert-px(2) solid;
   font-weight: bold;
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
index f66d35c..8b11df7 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentForm from './comment-form';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * CommentForm - Storybook Meta
  */
 export default {
   title: 'Organisms/Forms',
   component: CommentForm,
+  args: {
+    saveComment,
+  },
   argTypes: {
     className: {
       control: {
@@ -35,6 +41,16 @@ export default {
         required: false,
       },
     },
+    parentId: {
+      control: {
+        type: null,
+      },
+      description: 'The parent id if it is a reply.',
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     saveComment: {
       control: {
         type: null,
@@ -74,13 +90,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentForm>;
 
 const Template: ComponentStory<typeof CommentForm> = (args) => (
@@ -91,8 +100,3 @@ const Template: ComponentStory<typeof CommentForm> = (args) => (
  * Forms Stories - Comment
  */
 export const Comment = Template.bind({});
-Comment.args = {
-  saveComment: (reset: () => void) => {
-    reset();
-  },
-};
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx
index 0d387b5..c67ad6b 100644
--- a/src/components/organisms/forms/comment-form.test.tsx
+++ b/src/components/organisms/forms/comment-form.test.tsx
@@ -1,17 +1,20 @@
 import { render, screen } from '@test-utils';
 import CommentForm from './comment-form';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
 const title = 'Cum voluptas voluptatibus';
 
 describe('CommentForm', () => {
   it('renders a form', () => {
-    render(<CommentForm saveComment={() => null} />);
+    render(<CommentForm saveComment={saveComment} />);
     expect(screen.getByRole('form')).toBeInTheDocument();
   });
 
   it('renders an optional title', () => {
     render(
-      <CommentForm saveComment={() => null} title={title} titleLevel={2} />
+      <CommentForm saveComment={saveComment} title={title} titleLevel={2} />
     );
     expect(
       screen.getByRole('heading', { level: 2, name: title })
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index d7cb0f5..9e0abdf 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -7,6 +7,14 @@ import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './comment-form.module.scss';
 
+export type CommentFormData = {
+  comment: string;
+  email: string;
+  name: string;
+  parentId?: number;
+  website?: string;
+};
+
 export type CommentFormProps = {
   /**
    * Set additional classnames to the form wrapper.
@@ -16,11 +24,15 @@ export type CommentFormProps = {
    * Pass a component to print a success/error message.
    */
   Notice?: ReactNode;
+  /**
+   * The comment parent id.
+   */
+  parentId?: number;
   /**
    * A callback function to save comment. It takes a function as parameter to
    * reset the form.
    */
-  saveComment: (reset: () => void) => void;
+  saveComment: (data: CommentFormData, reset: () => void) => Promise<void>;
   /**
    * The form title.
    */
@@ -34,6 +46,7 @@ export type CommentFormProps = {
 const CommentForm: FC<CommentFormProps> = ({
   className = '',
   Notice,
+  parentId,
   saveComment,
   title,
   titleLevel = 2,
@@ -95,7 +108,9 @@ const CommentForm: FC<CommentFormProps> = ({
    */
   const submitHandler = () => {
     setIsSubmitting(true);
-    saveComment(resetForm);
+    saveComment({ comment, email, name, parentId, website }, resetForm).then(
+      () => setIsSubmitting(false)
+    );
   };
 
   return (
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index 3794b06..c31b77a 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentComponent from './comment';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * Comment - Storybook Meta
  */
 export default {
   title: 'Organisms/Layout',
   component: CommentComponent,
+  args: {
+    saveComment,
+  },
   argTypes: {
     author: {
       description: 'The author data.',
@@ -51,6 +57,29 @@ export default {
         required: true,
       },
     },
+    Notice: {
+      control: {
+        type: null,
+      },
+      description: 'A component to display a success or error message.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'function',
+        required: false,
+      },
+    },
+    parentId: {
+      control: {
+        type: null,
+      },
+      description: 'The parent id if it is a reply.',
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     publication: {
       description: 'The publication date.',
       type: {
@@ -73,13 +102,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentComponent>;
 
 const Template: ComponentStory<typeof CommentComponent> = (args) => (
@@ -100,7 +122,6 @@ Comment.args = {
     'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.',
   id: 2,
   publication: '2021-04-03 23:04:24',
-  saveComment: () => null,
   // @ts-ignore - Needed because of the placeholder image.
   unoptimized: true,
 };
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
index 4961722..02a51dc 100644
--- a/src/components/organisms/layout/comment.test.tsx
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -11,13 +11,15 @@ const content =
   'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
 const publication = '2021-04-03 23:04:24';
 const id = 5;
-
+const saveComment = async () => {
+  /** Do nothing. */
+};
 const data = {
   author,
   content,
   id,
   publication,
-  saveComment: () => null,
+  saveComment,
 };
 
 const formattedDate = getFormattedDate(publication);
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index 248efc2..6df393b 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -25,7 +25,7 @@ export type CommentAuthor = {
   url?: string;
 };
 
-export type CommentProps = {
+export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
   /**
    * The comment author data.
    */
@@ -50,10 +50,6 @@ export type CommentProps = {
    * The comment date and time separated with a space.
    */
   publication: string;
-  /**
-   * A callback function to save comment form data.
-   */
-  saveComment: CommentFormProps['saveComment'];
 };
 
 /**
@@ -66,6 +62,7 @@ const Comment: FC<CommentProps> = ({
   canReply = true,
   content,
   id,
+  Notice,
   parentId,
   publication,
   saveComment,
@@ -169,7 +166,10 @@ const Comment: FC<CommentProps> = ({
           className={styles.date}
           groupClassName={styles.date__item}
         />
-        <div className={styles.body}>{content}</div>
+        <div
+          className={styles.body}
+          dangerouslySetInnerHTML={{ __html: content }}
+        />
         <footer className={styles.footer}>
           {canReply && (
             <Button kind="tertiary" onClick={() => setIsReplying(!isReplying)}>
@@ -180,6 +180,8 @@ const Comment: FC<CommentProps> = ({
       </article>
       {isReplying && (
         <CommentForm
+          Notice={Notice}
+          parentId={id as number}
           saveComment={saveComment}
           title={formTitle}
           className={`${styles.wrapper} ${styles['wrapper--form']}`}
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 9edf368..4d95205 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentsListComponent, { Comment } from './comments-list';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * CommentsList - Storybook Meta
  */
 export default {
   title: 'Organisms/Layout/CommentsList',
   component: CommentsListComponent,
+  args: {
+    saveComment,
+  },
   argTypes: {
     comments: {
       control: {
@@ -30,6 +36,19 @@ export default {
         required: true,
       },
     },
+    Notice: {
+      control: {
+        type: null,
+      },
+      description: 'A component to display a success or error message.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'function',
+        required: false,
+      },
+    },
     saveComment: {
       control: {
         type: null,
@@ -44,13 +63,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentsListComponent>;
 
 const Template: ComponentStory<typeof CommentsListComponent> = (args) => (
@@ -130,7 +142,6 @@ export const WithoutChildComments = Template.bind({});
 WithoutChildComments.args = {
   comments,
   depth: 0,
-  saveComment: () => null,
 };
 
 /**
@@ -140,5 +151,4 @@ export const WithChildComments = Template.bind({});
 WithChildComments.args = {
   comments,
   depth: 1,
-  saveComment: () => null,
 };
diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx
index 542b1df..e135ec9 100644
--- a/src/components/organisms/layout/comments-list.test.tsx
+++ b/src/components/organisms/layout/comments-list.test.tsx
@@ -57,10 +57,14 @@ const comments: Comment[] = [
   },
 ];
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 describe('CommentsList', () => {
   it('renders a comments list', () => {
     render(
-      <CommentsList comments={comments} depth={1} saveComment={() => null} />
+      <CommentsList comments={comments} depth={1} saveComment={saveComment} />
     );
   });
 });
diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx
index 03f508e..f04354c 100644
--- a/src/components/organisms/layout/comments-list.tsx
+++ b/src/components/organisms/layout/comments-list.tsx
@@ -8,7 +8,7 @@ export type Comment = Omit<CommentProps, 'canReply' | 'saveComment'> & {
   child?: Comment[];
 };
 
-export type CommentsListProps = {
+export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & {
   /**
    * An array of comments.
    */
@@ -17,10 +17,6 @@ export type CommentsListProps = {
    * The maximum depth. Use `0` to not display nested comments.
    */
   depth: 0 | 1 | 2 | 3 | 4;
-  /**
-   * A callback function to save comment form data.
-   */
-  saveComment: CommentProps['saveComment'];
 };
 
 /**
@@ -31,6 +27,7 @@ export type CommentsListProps = {
 const CommentsList: FC<CommentsListProps> = ({
   comments,
   depth,
+  Notice,
   saveComment,
 }) => {
   /**
@@ -48,8 +45,9 @@ const CommentsList: FC<CommentsListProps> = ({
     return commentsList.map(({ child, ...comment }) => (
       <li key={comment.id} className={styles.item}>
         <SingleComment
-          saveComment={saveComment}
           canReply={!isLastLevel}
+          Notice={Notice}
+          saveComment={saveComment}
           {...comment}
         />
         {child && !isLastLevel && (
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 480c76e..8e518aa 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -85,12 +85,12 @@ export default {
     },
     id: {
       control: {
-        type: 'text',
+        type: 'number',
       },
       description: 'The page id.',
       type: {
-        name: 'string',
-        required: true,
+        name: 'number',
+        required: false,
       },
     },
     intro: {
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index ac021ba..045b8c1 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -1,4 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
+import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import Sidebar from '@components/atoms/layout/sidebar';
 import PageFooter, {
   type PageFooterProps,
@@ -9,15 +10,19 @@ import PageHeader, {
 import Breadcrumb, {
   type BreadcrumbItem,
 } from '@components/molecules/nav/breadcrumb';
-import CommentForm from '@components/organisms/forms/comment-form';
+import CommentForm, {
+  type CommentFormProps,
+} from '@components/organisms/forms/comment-form';
 import CommentsList, {
   type CommentsListProps,
 } from '@components/organisms/layout/comments-list';
 import TableOfContents from '@components/organisms/widgets/table-of-contents';
+import { type SendCommentVars } from '@services/graphql/api';
+import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
-import { FC, ReactNode, useRef } from 'react';
+import { FC, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
-import Layout, { LayoutProps } from '../layout/layout';
+import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
 
 export type PageLayoutProps = {
@@ -45,6 +50,10 @@ export type PageLayoutProps = {
    * The header metadata.
    */
   headerMeta?: PageHeaderProps['meta'];
+  /**
+   * The page id.
+   */
+  id?: number;
   /**
    * The page introduction.
    */
@@ -79,6 +88,7 @@ const PageLayout: FC<PageLayoutProps> = ({
   comments,
   footerMeta,
   headerMeta,
+  id,
   intro,
   isHome = false,
   widgets,
@@ -106,8 +116,56 @@ const PageLayout: FC<PageLayoutProps> = ({
     ? 'article--has-comments'
     : 'article--no-comments';
 
-  const saveComment = () => {
-    return null;
+  const [status, setStatus] = useState<NoticeKind>('info');
+  const [statusMessage, setStatusMessage] = useState<string>('');
+  const isReplyRef = useRef<boolean>(false);
+
+  const saveComment: CommentFormProps['saveComment'] = async (data, reset) => {
+    if (!id) throw new Error('Page id missing. Cannot save comment.');
+
+    const { comment: commentBody, email, name, parentId, website } = data;
+    const commentData: SendCommentVars = {
+      author: name,
+      authorEmail: email,
+      authorUrl: website || '',
+      clientMutationId: 'contact',
+      commentOn: id,
+      content: commentBody,
+      parent: parentId,
+    };
+    const { comment, success } = await sendComment(commentData);
+
+    isReplyRef.current = !!parentId;
+
+    if (success) {
+      setStatus('success');
+      const successPrefix = intl.formatMessage({
+        defaultMessage: 'Thanks, your comment was successfully sent.',
+        description: 'PageLayout: comment form success message',
+        id: 'B290Ph',
+      });
+      const successMessage = comment?.approved
+        ? intl.formatMessage({
+            defaultMessage: 'It has been approved.',
+            id: 'g3+Ahv',
+            description: 'PageLayout: comment approved.',
+          })
+        : intl.formatMessage({
+            defaultMessage: 'It is now awaiting moderation.',
+            id: 'Vmj5cw',
+            description: 'PageLayout: comment awaiting moderation',
+          });
+      setStatusMessage(`${successPrefix} ${successMessage}`);
+      reset();
+    } else {
+      const error = intl.formatMessage({
+        defaultMessage: 'An error occurred:',
+        description: 'PageLayout: comment form error message',
+        id: 'fkcTGp',
+      });
+      setStatus('error');
+      setStatusMessage(error);
+    }
   };
 
   return (
@@ -154,15 +212,36 @@ const PageLayout: FC<PageLayoutProps> = ({
             <section className={styles.comments__section}>
               <Heading level={2}>{commentsTitle}</Heading>
               <CommentsList
-                saveComment={saveComment}
                 comments={comments}
-                depth={2}
+                depth={1}
+                Notice={
+                  isReplyRef.current === true ? (
+                    <Notice
+                      kind={status}
+                      message={statusMessage}
+                      className={styles.notice}
+                    />
+                  ) : undefined
+                }
+                saveComment={saveComment}
               />
             </section>
           )}
           {allowComments && (
             <section className={styles.comments__section}>
-              <CommentForm saveComment={saveComment} title={commentFormTitle} />
+              <CommentForm
+                saveComment={saveComment}
+                title={commentFormTitle}
+                Notice={
+                  isReplyRef.current === false ? (
+                    <Notice
+                      kind={status}
+                      message={statusMessage}
+                      className={styles.notice}
+                    />
+                  ) : undefined
+                }
+              />
             </section>
           )}
         </div>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
new file mode 100644
index 0000000..6a47c16
--- /dev/null
+++ b/src/pages/article/[slug].tsx
@@ -0,0 +1,251 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Link from '@components/atoms/links/link';
+import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Sharing from '@components/organisms/widgets/sharing';
+import PageLayout, {
+  type PageLayoutProps,
+} from '@components/templates/page/page-layout';
+import {
+  getAllArticlesSlugs,
+  getArticleBySlug,
+} from '@services/graphql/articles';
+import { getPostComments } from '@services/graphql/comments';
+import { type Article, type Comment } from '@ts/types/app';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { ParsedUrlQuery } from 'querystring';
+import { useIntl } from 'react-intl';
+import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
+import useSWR from 'swr';
+
+type ArticlePageProps = {
+  comments: Comment[];
+  post: Article;
+  translation: Messages;
+};
+
+/**
+ * Article page.
+ */
+const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
+  const { content, id, intro, meta, slug, title } = post;
+  const { author, commentsCount, cover, dates, seo, thematics, topics } = meta;
+  const { data } = useSWR(() => id, getPostComments, {
+    fallbackData: comments,
+  });
+  const intl = useIntl();
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const blogLabel = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'Breadcrumb: blog label',
+    id: 'Es52wh',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'blog', name: blogLabel, url: '/blog' },
+    { id: 'article', name: title, url: `/article/${slug}` },
+  ];
+
+  const headerMeta: PageLayoutProps['headerMeta'] = {
+    author: author?.name,
+    publication: { date: dates.publication },
+    update: dates.update ? { date: dates.update } : undefined,
+    thematics:
+      thematics &&
+      thematics.map((thematic) => (
+        <Link key={thematic.id} href={`/thematique/${thematic.slug}`}>
+          {thematic.name}
+        </Link>
+      )),
+  };
+
+  const footerMeta: PageLayoutProps['footerMeta'] = {
+    topics:
+      topics &&
+      topics.map((topic) => {
+        return (
+          <ButtonLink key={topic.id} target={`/sujet/${topic.slug}`}>
+            {topic.name}
+          </ButtonLink>
+        );
+      }),
+  };
+
+  const { website } = useSettings();
+  const { asPath } = useRouter();
+  const pageUrl = `${website.url}${asPath}`;
+  const pagePublicationDate = new Date(dates.publication);
+  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    lastReviewed: dates.update,
+    name: seo.title,
+    description: seo.description,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${pageUrl}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const blogSchema: Blog = {
+    '@id': `${website.url}/#blog`,
+    '@type': 'Blog',
+    blogPost: { '@id': `${website.url}/#article` },
+    isPartOf: {
+      '@id': `${pageUrl}`,
+    },
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+  };
+
+  const blogPostSchema: BlogPosting = {
+    '@id': `${website.url}/#article`,
+    '@type': 'BlogPosting',
+    name: title,
+    description: intro,
+    articleBody: content,
+    author: { '@id': `${website.url}/#branding` },
+    commentCount: commentsCount,
+    copyrightYear: pagePublicationDate.getFullYear(),
+    creator: { '@id': `${website.url}/#branding` },
+    dateCreated: pagePublicationDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
+    datePublished: pagePublicationDate.toISOString(),
+    discussionUrl: `${pageUrl}/#comments`,
+    editor: { '@id': `${website.url}/#branding` },
+    headline: title,
+    image: cover?.src,
+    inLanguage: website.locales.default,
+    isPartOf: {
+      '@id': `${website.url}/blog`,
+    },
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+    thumbnailUrl: cover?.src,
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, blogSchema, blogPostSchema],
+  };
+
+  /**
+   * Convert the comments list to the right format.
+   *
+   * @param {Comment[]} list - The comments list.
+   * @returns {PageLayoutProps['comments']} - The formatted comments list.
+   */
+  const getCommentsList = (list: Comment[]): PageLayoutProps['comments'] => {
+    return list.map((comment) => {
+      const {
+        content: commentBody,
+        id: commentId,
+        meta: commentMeta,
+        parentId,
+        replies,
+      } = comment;
+      const { author: commentAuthor, date } = commentMeta;
+      const { name, avatar, website: authorUrl } = commentAuthor;
+
+      return {
+        author: { name, avatar: avatar!.src, url: authorUrl },
+        content: commentBody,
+        id: commentId,
+        publication: date,
+        child: getCommentsList(replies),
+        parentId,
+      };
+    });
+  };
+
+  return (
+    <>
+      <Head>
+        <title>{seo.title}</title>
+        <meta name="description" content={seo.description} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="article" />
+        <meta property="og:title" content={title} />
+        <meta property="og:description" content={intro} />
+      </Head>
+      <Script
+        id="schema-project"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        allowComments={true}
+        breadcrumb={breadcrumb}
+        comments={data && getCommentsList(data)}
+        footerMeta={footerMeta}
+        headerMeta={headerMeta}
+        id={id as number}
+        intro={intro}
+        title={title}
+        withToC={true}
+        widgets={[
+          <Sharing
+            key="sharing-widget"
+            data={{ excerpt: intro, title, url: pageUrl }}
+            media={[
+              'diaspora',
+              'email',
+              'facebook',
+              'journal-du-hacker',
+              'linkedin',
+              'twitter',
+            ]}
+          />,
+        ]}
+      >
+        {content}
+      </PageLayout>
+    </>
+  );
+};
+
+interface PostParams extends ParsedUrlQuery {
+  slug: string;
+}
+
+export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
+  locale,
+  params,
+}) => {
+  const post = await getArticleBySlug(params!.slug as PostParams['slug']);
+  const comments = await getPostComments(post.id as number);
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      comments: JSON.parse(JSON.stringify(comments)),
+      post: JSON.parse(JSON.stringify(post)),
+      translation,
+    },
+  };
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  const slugs = await getAllArticlesSlugs();
+  const paths = slugs.map((slug) => {
+    return { params: { slug } };
+  });
+
+  return {
+    paths,
+    fallback: true,
+  };
+};
+
+export default ArticlePage;
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts
index 171ab23..9f68ddc 100644
--- a/src/services/graphql/api.ts
+++ b/src/services/graphql/api.ts
@@ -6,6 +6,7 @@ import {
   articlesSlugQuery,
   totalArticlesQuery,
 } from './articles.query';
+import { sendCommentMutation } from './comments.mutation';
 import { commentsQuery } from './comments.query';
 import { sendMailMutation } from './contact.mutation';
 import {
@@ -21,7 +22,7 @@ import {
   totalTopicsQuery,
 } from './topics.query';
 
-export type Mutations = typeof sendMailMutation;
+export type Mutations = typeof sendMailMutation | typeof sendCommentMutation;
 
 export type Queries =
   | typeof articlesQuery
@@ -51,6 +52,10 @@ export type CommentsResponse<T> = {
   comments: T;
 };
 
+export type SendCommentResponse<T> = {
+  createComment: T;
+};
+
 export type SendMailResponse<T> = {
   sendEmail: T;
 };
@@ -101,6 +106,7 @@ export type ResponseMap<T> = {
   [articlesQuery]: ArticlesResponse<EdgesResponse<T>>;
   [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>;
   [commentsQuery]: CommentsResponse<NodesResponse<T>>;
+  [sendCommentMutation]: SendCommentResponse<T>;
   [sendMailMutation]: SendMailResponse<T>;
   [thematicBySlugQuery]: ThematicResponse<T>;
   [thematicsListQuery]: ThematicsResponse<EdgesResponse<T>>;
@@ -154,10 +160,53 @@ export type SearchVar = {
   search?: string;
 };
 
+export type SendCommentVars = {
+  /**
+   * The author name.
+   */
+  author: string;
+  /**
+   * The author e-mail address.
+   */
+  authorEmail: string;
+  /**
+   * The author website.
+   */
+  authorUrl: string;
+  /**
+   * A mutation id.
+   */
+  clientMutationId: string;
+  /**
+   * A post or page id.
+   */
+  commentOn: number;
+  /**
+   * The comment body.
+   */
+  content: string;
+  /**
+   * The comment parent.
+   */
+  parent?: number;
+};
+
 export type SendMailVars = {
+  /**
+   * The mail body.
+   */
   body: string;
+  /**
+   * A mutation id.
+   */
   clientMutationId: string;
+  /**
+   * The reply to e-mail address.
+   */
   replyTo: string;
+  /**
+   * The mail subject.
+   */
   subject: string;
 };
 
@@ -167,6 +216,7 @@ export type VariablesMap = {
   [articlesQuery]: EdgesVars;
   [articlesSlugQuery]: EdgesVars;
   [commentsQuery]: ByContentIdVar;
+  [sendCommentMutation]: SendCommentVars;
   [sendMailMutation]: SendMailVars;
   [thematicBySlugQuery]: BySlugVar;
   [thematicsListQuery]: EdgesVars;
diff --git a/src/services/graphql/comments.mutation.ts b/src/services/graphql/comments.mutation.ts
new file mode 100644
index 0000000..f52c7e9
--- /dev/null
+++ b/src/services/graphql/comments.mutation.ts
@@ -0,0 +1,30 @@
+/**
+ * Send comment mutation.
+ */
+export const sendCommentMutation = `mutation CreateComment(
+  $author: String!
+  $authorEmail: String!
+  $authorUrl: String!
+  $content: String!
+  $parent: ID = null
+  $commentOn: Int!
+  $clientMutationId: String!
+) {
+  createComment(
+    input: {
+      author: $author
+      authorEmail: $authorEmail
+      authorUrl: $authorUrl
+      content: $content
+      parent: $parent
+      commentOn: $commentOn
+      clientMutationId: $clientMutationId
+    }
+  ) {
+    clientMutationId
+    success
+    comment {
+      approved
+    }
+  }
+}`;
diff --git a/src/services/graphql/comments.ts b/src/services/graphql/comments.ts
new file mode 100644
index 0000000..28ddfd0
--- /dev/null
+++ b/src/services/graphql/comments.ts
@@ -0,0 +1,102 @@
+import { Comment } from '@ts/types/app';
+import { RawComment } from '@ts/types/raw-data';
+import { getAuthorFromRawData } from '@utils/helpers/author';
+import { fetchAPI, getAPIUrl, SendCommentVars } from './api';
+import { sendCommentMutation } from './comments.mutation';
+import { commentsQuery } from './comments.query';
+
+/**
+ * Create a comments tree with replies.
+ *
+ * @param {Comment[]} comments - A flatten comments list.
+ * @returns {Comment[]} An array of comments with replies.
+ */
+export const buildCommentsTree = (comments: Comment[]): Comment[] => {
+  type CommentsHashTable = {
+    [key: string]: Comment;
+  };
+
+  const hashTable: CommentsHashTable = Object.create(null);
+  const commentsTree: Comment[] = [];
+
+  comments.forEach(
+    (comment) => (hashTable[comment.id] = { ...comment, replies: [] })
+  );
+
+  comments.forEach((comment) => {
+    if (!comment.parentId) {
+      commentsTree.push(hashTable[comment.id]);
+    } else {
+      hashTable[comment.parentId].replies.push(hashTable[comment.id]);
+    }
+  });
+
+  return commentsTree;
+};
+
+/**
+ * Convert a comment from RawComment to Comment type.
+ *
+ * @param {RawComment} comment - A raw comment.
+ * @returns {Comment} A formatted comment.
+ */
+export const getCommentFromRawData = (comment: RawComment): Comment => {
+  const { author, databaseId, date, parentDatabaseId, ...data } = comment;
+
+  return {
+    id: databaseId,
+    meta: {
+      author: getAuthorFromRawData(author.node, 'comment'),
+      date,
+    },
+    parentId: parentDatabaseId,
+    replies: [],
+    ...data,
+  };
+};
+
+/**
+ * Retrieve a comments list by post id.
+ *
+ * @param {number} id - A post id.
+ * @returns {Promise<Comment[]>} The comments list.
+ */
+export const getPostComments = async (id: number): Promise<Comment[]> => {
+  const response = await fetchAPI<RawComment, typeof commentsQuery>({
+    api: getAPIUrl(),
+    query: commentsQuery,
+    variables: { contentId: id },
+  });
+
+  const comments = response.comments.nodes.map((comment) =>
+    getCommentFromRawData(comment)
+  );
+
+  return buildCommentsTree(comments);
+};
+
+export type SentComment = {
+  clientMutationId: string;
+  success: boolean;
+  comment: {
+    approved: boolean;
+  } | null;
+};
+
+/**
+ * Send a comment using GraphQL API.
+ *
+ * @param {SendCommentVars} data - The comment data.
+ * @returns {Promise<SentEmail>} The mutation response.
+ */
+export const sendComment = async (
+  data: SendCommentVars
+): Promise<SentComment> => {
+  const response = await fetchAPI<SentComment, typeof sendCommentMutation>({
+    api: getAPIUrl(),
+    query: sendCommentMutation,
+    variables: { ...data },
+  });
+
+  return response.createComment;
+};
-- 
cgit v1.2.3


From c77c58e18143233be042c4980a6ed08ae9beac52 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 16 May 2022 19:40:23 +0200
Subject: chore: adjust and complete missing styles

* add logo to topics pages and links
* add Prism styles to articles
* and a few other adjustements
---
 src/components/atoms/forms/form.tsx                |   3 +-
 src/components/atoms/links/link.module.scss        |  23 --
 .../atoms/lists/description-list-item.module.scss  |   2 +
 src/components/molecules/layout/meta.tsx           |  59 ++--
 src/components/molecules/layout/page-footer.tsx    |   4 +-
 .../molecules/layout/page-header.module.scss       |   6 +
 src/components/molecules/layout/page-header.tsx    |  11 +-
 src/components/organisms/forms/comment-form.tsx    |  12 +-
 .../organisms/widgets/sharing.stories.tsx          |  21 +-
 src/components/organisms/widgets/sharing.tsx       |   7 +-
 .../templates/page/page-layout.module.scss         |   6 +
 .../templates/page/page-layout.stories.tsx         |   2 +-
 src/components/templates/page/page-layout.tsx      |  17 +-
 src/pages/article/[slug].tsx                       |  43 ++-
 src/pages/blog/index.tsx                           |  12 +-
 src/pages/cv.tsx                                   |  19 +-
 src/pages/recherche/index.tsx                      |  12 +-
 src/pages/sujet/[slug].tsx                         |  35 ++-
 src/services/graphql/articles.query.ts             |  11 +
 src/services/graphql/topics.query.ts               |  11 +
 src/styles/base/_typography.scss                   |  24 +-
 src/styles/components/_wp-blocks.scss              | 166 ------------
 src/styles/globals.scss                            |  10 +-
 src/styles/pages/article.module.scss               |  36 +++
 src/styles/pages/partials/_article-headings.scss   |  57 ++++
 src/styles/pages/partials/_article-links.scss      | 104 +++++++
 src/styles/pages/partials/_article-lists.scss      |  65 +++++
 src/styles/pages/partials/_article-media.scss      |  11 +
 src/styles/pages/partials/_article-prism.scss      | 301 +++++++++++++++++++++
 src/styles/pages/partials/_article-wp-blocks.scss  | 168 ++++++++++++
 src/styles/pages/topic.module.scss                 |   6 +
 src/ts/types/app.ts                                |   1 +
 src/ts/types/raw-data.ts                           |   7 +-
 src/utils/helpers/pages.ts                         |   5 +-
 src/utils/hooks/use-add-prism-class-attr.tsx       |  60 ++++
 35 files changed, 1036 insertions(+), 301 deletions(-)
 delete mode 100644 src/styles/components/_wp-blocks.scss
 create mode 100644 src/styles/pages/article.module.scss
 create mode 100644 src/styles/pages/partials/_article-headings.scss
 create mode 100644 src/styles/pages/partials/_article-links.scss
 create mode 100644 src/styles/pages/partials/_article-lists.scss
 create mode 100644 src/styles/pages/partials/_article-media.scss
 create mode 100644 src/styles/pages/partials/_article-prism.scss
 create mode 100644 src/styles/pages/partials/_article-wp-blocks.scss
 create mode 100644 src/styles/pages/topic.module.scss
 create mode 100644 src/utils/hooks/use-add-prism-class-attr.tsx

(limited to 'src/components/organisms/forms/comment-form.tsx')

diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
index ef8dce4..b819aea 100644
--- a/src/components/atoms/forms/form.tsx
+++ b/src/components/atoms/forms/form.tsx
@@ -35,7 +35,6 @@ export type FormProps = {
  */
 const Form: FC<FormProps> = ({
   children,
-  className = '',
   grouped = true,
   onSubmit,
   ...props
@@ -68,7 +67,7 @@ const Form: FC<FormProps> = ({
   };
 
   return (
-    <form onSubmit={handleSubmit} className={className} {...props}>
+    <form onSubmit={handleSubmit} {...props}>
       {getFormItems()}
     </form>
   );
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
index 5c97bd2..1b89727 100644
--- a/src/components/atoms/links/link.module.scss
+++ b/src/components/atoms/links/link.module.scss
@@ -2,29 +2,6 @@
 @use "@styles/abstracts/variables" as var;
 
 .link {
-  background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%)
-    0 0 / 100% 201% no-repeat;
-  color: var(--color-primary);
-  text-decoration-thickness: 0.15em;
-  text-underline-offset: 20%;
-  transition: all 0.3s linear 0s, text-decoration 0.18s ease-in-out 0s;
-
-  &:hover {
-    color: var(--color-primary-light);
-    text-decoration-thickness: 0.25em;
-  }
-
-  &:focus {
-    background-position: 0 100%;
-    color: var(--color-fg-inverted);
-  }
-
-  &:active {
-    background-position: 0 0;
-    color: var(--color-primary-dark);
-    text-decoration-thickness: 18%;
-  }
-
   &[hreflang] {
     &::after {
       display: inline-block;
diff --git a/src/components/atoms/lists/description-list-item.module.scss b/src/components/atoms/lists/description-list-item.module.scss
index 60cad57..aba90ce 100644
--- a/src/components/atoms/lists/description-list-item.module.scss
+++ b/src/components/atoms/lists/description-list-item.module.scss
@@ -27,6 +27,8 @@
   }
 
   &--inline-values {
+    row-gap: var(--spacing-2xs);
+
     .term {
       flex: 1 1 100%;
     }
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index 1f6219a..74bd4ff 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -92,13 +92,17 @@ export type MetaData = {
    */
   topics?: string[] | JSX.Element[];
   /**
-   * A total.
+   * A total number of posts.
    */
-  total?: string;
+  total?: number;
   /**
    * The update date.
    */
   update?: MetaDate;
+  /**
+   * An url.
+   */
+  website?: string;
 };
 
 export type MetaKey = keyof MetaData;
@@ -145,80 +149,86 @@ const Meta: FC<MetaProps> = ({
       case 'author':
         return intl.formatMessage({
           defaultMessage: 'Written by:',
-          id: 'OI0N37',
           description: 'Meta: author label',
+          id: 'OI0N37',
         });
       case 'comments':
         return intl.formatMessage({
           defaultMessage: 'Comments:',
-          id: 'jTVIh8',
           description: 'Meta: comments label',
+          id: 'jTVIh8',
         });
       case 'creation':
         return intl.formatMessage({
           defaultMessage: 'Created on:',
-          id: 'b4fdYE',
           description: 'Meta: creation date label',
+          id: 'b4fdYE',
         });
       case 'license':
         return intl.formatMessage({
           defaultMessage: 'License:',
-          id: 'AuGklx',
           description: 'Meta: license label',
+          id: 'AuGklx',
         });
       case 'popularity':
         return intl.formatMessage({
           defaultMessage: 'Popularity:',
-          id: 'pWTj2W',
           description: 'Meta: popularity label',
+          id: 'pWTj2W',
         });
       case 'publication':
         return intl.formatMessage({
           defaultMessage: 'Published on:',
-          id: 'QGi5uD',
           description: 'Meta: publication date label',
+          id: 'QGi5uD',
         });
       case 'readingTime':
         return intl.formatMessage({
           defaultMessage: 'Reading time:',
-          id: 'EbFvsM',
           description: 'Meta: reading time label',
+          id: 'EbFvsM',
         });
       case 'repositories':
         return intl.formatMessage({
           defaultMessage: 'Repositories:',
-          id: 'DssFG1',
           description: 'Meta: repositories label',
+          id: 'DssFG1',
         });
       case 'technologies':
         return intl.formatMessage({
           defaultMessage: 'Technologies:',
-          id: 'ADQmDF',
           description: 'Meta: technologies label',
+          id: 'ADQmDF',
         });
       case 'thematics':
         return intl.formatMessage({
           defaultMessage: 'Thematics:',
-          id: 'bz53Us',
           description: 'Meta: thematics label',
+          id: 'bz53Us',
         });
       case 'topics':
         return intl.formatMessage({
           defaultMessage: 'Topics:',
-          id: 'gJNaBD',
           description: 'Meta: topics label',
+          id: 'gJNaBD',
         });
       case 'total':
         return intl.formatMessage({
           defaultMessage: 'Total:',
-          id: '92zgdp',
           description: 'Meta: total label',
+          id: '92zgdp',
         });
       case 'update':
         return intl.formatMessage({
           defaultMessage: 'Updated on:',
-          id: 'tLC7bh',
           description: 'Meta: update date label',
+          id: 'tLC7bh',
+        });
+      case 'website':
+        return intl.formatMessage({
+          defaultMessage: 'Official website:',
+          description: 'Meta: official website label',
+          id: 'GRyyfy',
         });
       default:
         return '';
@@ -279,8 +289,8 @@ const Meta: FC<MetaProps> = ({
       {
         defaultMessage:
           '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>',
-        id: '02rgLO',
         description: 'Meta: comments count',
+        id: '02rgLO',
       },
       {
         a11y: (chunks: ReactNode) => (
@@ -316,6 +326,23 @@ const Meta: FC<MetaProps> = ({
       case 'publication':
       case 'update':
         return getDate(value as MetaDate);
+      case 'total':
+        return intl.formatMessage(
+          {
+            defaultMessage:
+              '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
+            description: 'BlogPage: posts count meta',
+            id: 'OF5cPz',
+          },
+          { postsCount: value as number }
+        );
+      case 'website':
+        const url = value as string;
+        return (
+          <Link href={url} external={true}>
+            {url}
+          </Link>
+        );
       default:
         return value as string | ReactNode | ReactNode[];
     }
diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx
index e998b1e..97e449f 100644
--- a/src/components/molecules/layout/page-footer.tsx
+++ b/src/components/molecules/layout/page-footer.tsx
@@ -19,7 +19,9 @@ export type PageFooterProps = {
  */
 const PageFooter: FC<PageFooterProps> = ({ meta, ...props }) => {
   return (
-    <footer {...props}>{meta && <Meta data={meta} layout="column" />}</footer>
+    <footer {...props}>
+      {meta && <Meta data={meta} withSeparator={false} />}
+    </footer>
   );
 };
 
diff --git a/src/components/molecules/layout/page-header.module.scss b/src/components/molecules/layout/page-header.module.scss
index 4c7df5f..232023a 100644
--- a/src/components/molecules/layout/page-header.module.scss
+++ b/src/components/molecules/layout/page-header.module.scss
@@ -56,3 +56,9 @@
 .meta {
   font-size: var(--font-size-sm);
 }
+
+.intro {
+  > *:last-child {
+    margin-bottom: 0;
+  }
+}
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
index 9abe9af..6759c7f 100644
--- a/src/components/molecules/layout/page-header.tsx
+++ b/src/components/molecules/layout/page-header.tsx
@@ -1,5 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import Meta, { type MetaData } from './meta';
 import styles from './page-header.module.scss';
 
@@ -19,7 +19,7 @@ export type PageHeaderProps = {
   /**
    * The page title.
    */
-  title: string;
+  title: ReactNode;
 };
 
 /**
@@ -35,9 +35,12 @@ const PageHeader: FC<PageHeaderProps> = ({
 }) => {
   const getIntro = () => {
     return typeof intro === 'string' ? (
-      <div dangerouslySetInnerHTML={{ __html: intro }} />
+      <div
+        className={styles.intro}
+        dangerouslySetInnerHTML={{ __html: intro }}
+      />
     ) : (
-      <div>{intro}</div>
+      <div className={styles.intro}>{intro}</div>
     );
   };
 
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index 9e0abdf..5ff4ea4 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -1,5 +1,5 @@
 import Button from '@components/atoms/buttons/button';
-import Form from '@components/atoms/forms/form';
+import Form, { type FormProps } from '@components/atoms/forms/form';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
@@ -15,11 +15,7 @@ export type CommentFormData = {
   website?: string;
 };
 
-export type CommentFormProps = {
-  /**
-   * Set additional classnames to the form wrapper.
-   */
-  className?: string;
+export type CommentFormProps = Pick<FormProps, 'className'> & {
   /**
    * Pass a component to print a success/error message.
    */
@@ -44,12 +40,12 @@ export type CommentFormProps = {
 };
 
 const CommentForm: FC<CommentFormProps> = ({
-  className = '',
   Notice,
   parentId,
   saveComment,
   title,
   titleLevel = 2,
+  ...props
 }) => {
   const intl = useIntl();
   const [name, setName] = useState<string>('');
@@ -116,9 +112,9 @@ const CommentForm: FC<CommentFormProps> = ({
   return (
     <Form
       onSubmit={submitHandler}
-      className={className}
       aria-label={formAriaLabel}
       aria-labelledby={formLabelledBy}
+      {...props}
     >
       {title && (
         <Heading id={formId} level={titleLevel}>
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
index 47213b6..59b86d3 100644
--- a/src/components/organisms/widgets/sharing.stories.tsx
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SharingWidget from './sharing';
 
 /**
@@ -9,6 +8,19 @@ export default {
   title: 'Organisms/Widgets',
   component: SharingWidget,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the sharing links list.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     data: {
       description: 'The page data.',
       type: {
@@ -58,13 +70,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SharingWidget>;
 
 const Template: ComponentStory<typeof SharingWidget> = (args) => (
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
index 85dadb0..c63f5db 100644
--- a/src/components/organisms/widgets/sharing.tsx
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -22,6 +22,10 @@ export type SharingData = {
 };
 
 export type SharingProps = {
+  /**
+   * Set additional classnames to the sharing links list.
+   */
+  className?: string;
   /**
    * The page data to share.
    */
@@ -46,6 +50,7 @@ export type SharingProps = {
  * Render a list of sharing links inside a widget.
  */
 const Sharing: FC<SharingProps> = ({
+  className = '',
   data,
   media,
   expanded = true,
@@ -201,7 +206,7 @@ const Sharing: FC<SharingProps> = ({
 
   return (
     <Widget expanded={expanded} level={level} title={widgetTitle} {...props}>
-      <ul className={styles.list}>{getItems()}</ul>
+      <ul className={`${styles.list} ${className}`}>{getItems()}</ul>
     </Widget>
   );
 };
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index 7602492..83e5a80 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -72,6 +72,7 @@
 
 .footer {
   grid-column: 2;
+  margin: var(--spacing-sm) 0 var(--spacing-2xs);
 }
 
 .comments {
@@ -87,4 +88,9 @@
     grid-column: 2;
     margin: var(--spacing-md) 0 0;
   }
+
+  &__form {
+    max-width: 40ch;
+    margin: auto;
+  }
 }
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 002b951..8af5f98 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -473,7 +473,7 @@ export const Blog = Template.bind({});
 Blog.args = {
   breadcrumb: postsListBreadcrumb,
   title: 'Blog',
-  headerMeta: { total: `${posts.length} posts` },
+  headerMeta: { total: posts.length },
   children: (
     <>
       <PostsList posts={posts} byYear={true} total={posts.length} />
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index bc90f4c..f3f3ea8 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -20,7 +20,7 @@ import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
-import { FC, ReactNode, useRef, useState } from 'react';
+import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
 import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
@@ -33,6 +33,11 @@ export type PageLayoutProps = Pick<
    * True if the page accepts new comments. Default: false.
    */
   allowComments?: boolean;
+  bodyAttributes?: HTMLAttributes<HTMLDivElement>;
+  /**
+   * Set additional classnames to the body wrapper.
+   */
+  bodyClassName?: string;
   /**
    * The breadcrumb items.
    */
@@ -83,6 +88,8 @@ export type PageLayoutProps = Pick<
 const PageLayout: FC<PageLayoutProps> = ({
   children,
   allowComments = false,
+  bodyAttributes,
+  bodyClassName = '',
   breadcrumb,
   breadcrumbSchema,
   comments,
@@ -91,8 +98,8 @@ const PageLayout: FC<PageLayoutProps> = ({
   id,
   intro,
   isHome = false,
-  widgets,
   title,
+  widgets,
   withToC = false,
 }) => {
   const intl = useIntl();
@@ -202,11 +209,12 @@ const PageLayout: FC<PageLayoutProps> = ({
       {typeof children === 'string' ? (
         <div
           ref={bodyRef}
-          className={styles.body}
+          className={`${styles.body} ${bodyClassName}`}
           dangerouslySetInnerHTML={{ __html: children }}
+          {...bodyAttributes}
         />
       ) : (
-        <div ref={bodyRef} className={styles.body}>
+        <div ref={bodyRef} className={`${styles.body} ${bodyClassName}`}>
           {children}
         </div>
       )}
@@ -245,6 +253,7 @@ const PageLayout: FC<PageLayoutProps> = ({
           {allowComments && (
             <section className={styles.comments__section}>
               <CommentForm
+                className={styles.comments__form}
                 saveComment={saveComment}
                 title={commentFormTitle}
                 Notice={
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 5eeabd9..995e3a9 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -1,5 +1,6 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Sharing from '@components/organisms/widgets/sharing';
 import PageLayout, {
   type PageLayoutProps,
@@ -9,15 +10,20 @@ import {
   getArticleBySlug,
 } from '@services/graphql/articles';
 import { getPostComments } from '@services/graphql/comments';
+import styles from '@styles/pages/article.module.scss';
 import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
+import usePrismPlugins, { PrismPlugin } from '@utils/hooks/use-prism-plugins';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
+import { HTMLAttributes } from 'react';
+import { useIntl } from 'react-intl';
 import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
@@ -54,16 +60,31 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       )),
   };
 
+  const intl = useIntl();
+  const footerMetaLabel = intl.formatMessage({
+    defaultMessage: 'Read more articles about:',
+    description: 'ArticlePage: footer topics list label',
+    id: '50xc4o',
+  });
+
   const footerMeta: PageLayoutProps['footerMeta'] = {
-    topics:
-      topics &&
-      topics.map((topic) => {
+    custom: topics && {
+      label: footerMetaLabel,
+      value: topics.map((topic) => {
         return (
-          <ButtonLink key={topic.id} target={`/sujet/${topic.slug}`}>
+          <ButtonLink
+            key={topic.id}
+            target={`/sujet/${topic.slug}`}
+            className={styles.btn}
+          >
+            {topic.logo && (
+              <ResponsiveImage className={styles.btn__icon} {...topic.logo} />
+            )}{' '}
             {topic.name}
           </ButtonLink>
         );
       }),
+    },
   };
 
   const { website } = useSettings();
@@ -156,6 +177,15 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
     });
   };
 
+  const prismPlugins: PrismPlugin[] = ['command-line', 'line-numbers'];
+  const { pluginsAttribute, pluginsClassName } = usePrismPlugins(prismPlugins);
+  useAddPrismClassAttr({
+    attributes: {
+      'data-filter-output': '#output#"',
+    },
+    classNames: pluginsClassName,
+  });
+
   return (
     <>
       <Head>
@@ -173,6 +203,10 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       />
       <PageLayout
         allowComments={true}
+        bodyAttributes={{
+          ...(pluginsAttribute as HTMLAttributes<HTMLDivElement>),
+        }}
+        bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
         comments={data && getCommentsList(data)}
@@ -185,6 +219,7 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
         widgets={[
           <Sharing
             key="sharing-widget"
+            className={styles.widget}
             data={{ excerpt: intro, title, url: pageUrl }}
             media={[
               'diaspora',
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index a5ef045..b6ce221 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -114,16 +114,6 @@ const BlogPage: NextPage<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  const postsCount = intl.formatMessage(
-    {
-      defaultMessage:
-        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
-      id: 'OF5cPz',
-      description: 'BlogPage: posts count meta',
-    },
-    { postsCount: totalArticles }
-  );
-
   /**
    * Retrieve the formatted meta.
    *
@@ -231,7 +221,7 @@ const BlogPage: NextPage<BlogPageProps> = ({
         title={title}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        headerMeta={{ total: postsCount }}
+        headerMeta={{ total: totalArticles }}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index b3dec10..7936c84 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -1,4 +1,6 @@
+import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
+import List from '@components/atoms/lists/list';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout, {
@@ -9,11 +11,12 @@ import styles from '@styles/pages/cv.module.scss';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
+import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { ReactNode } from 'react';
+import React, { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
 import { AboutPage, Graph, WebPage } from 'schema-dts';
 
@@ -141,6 +144,18 @@ const CVPage: NextPage = () => {
     '@graph': [webpageSchema, cvSchema],
   };
 
+  const components: NestedMDXComponents = {
+    a: (props) => <Link external={true} {...props} />,
+    h1: (props) => <Heading level={1} {...props} />,
+    h2: (props) => <Heading level={2} {...props} />,
+    h3: (props) => <Heading level={3} {...props} />,
+    h4: (props) => <Heading level={4} {...props} />,
+    h5: (props) => <Heading level={5} {...props} />,
+    h6: (props) => <Heading level={6} {...props} />,
+    Link: (props) => <Link {...props} />,
+    List: (props) => <List {...props} />,
+  };
+
   return (
     <PageLayout
       breadcrumb={breadcrumbItems}
@@ -166,7 +181,7 @@ const CVPage: NextPage = () => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
-      <CVContent />
+      <CVContent components={components} />
     </PageLayout>
   );
 };
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 0a7dc60..d88a293 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -140,16 +140,6 @@ const SearchPage: NextPage<SearchPageProps> = ({
     getTotalArticles(query.s as string)
   );
 
-  const postsCount = intl.formatMessage(
-    {
-      defaultMessage:
-        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
-      id: 'LtsVOx',
-      description: 'SearchPage: posts count meta',
-    },
-    { postsCount: totalArticles || 0 }
-  );
-
   /**
    * Retrieve the formatted meta.
    *
@@ -244,7 +234,7 @@ const SearchPage: NextPage<SearchPageProps> = ({
         title={title}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        headerMeta={{ total: postsCount }}
+        headerMeta={{ total: totalArticles }}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 447d969..348fe05 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,4 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -10,6 +11,7 @@ import {
   getTopicsPreview,
   getTotalTopics,
 } from '@services/graphql/topics';
+import styles from '@styles/pages/topic.module.scss';
 import { type Article, type PageLink, type Topic } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
@@ -35,7 +37,14 @@ export type TopicPageProps = {
 
 const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const { content, intro, meta, slug, title } = currentTopic;
-  const { articles, dates, seo, thematics } = meta;
+  const {
+    articles,
+    cover,
+    dates,
+    seo,
+    thematics,
+    website: officialWebsite,
+  } = meta;
   const intl = useIntl();
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -45,6 +54,8 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
     update: dates.update ? { date: dates.update } : undefined,
+    website: officialWebsite,
+    total: articles ? articles.length : undefined,
   };
 
   const { website } = useSettings();
@@ -98,10 +109,10 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
         ...remainingData
       } = article;
 
-      const { cover, ...remainingMeta } = articleMeta;
+      const { cover: articleCover, ...remainingMeta } = articleMeta;
 
       return {
-        cover,
+        cover: articleCover,
         excerpt: articleIntro,
         meta: getPostMeta(remainingMeta),
         url: `/article/${articleSlug}`,
@@ -122,6 +133,15 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
     id: '/sRqPT',
   });
 
+  const getPageHeading = () => {
+    return (
+      <>
+        {cover && <ResponsiveImage className={styles.logo} {...cover} />}
+        {title}
+      </>
+    );
+  };
+
   return (
     <>
       <Head>
@@ -140,7 +160,7 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
       <PageLayout
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        title={title}
+        title={getPageHeading()}
         intro={intro}
         headerMeta={headerMeta}
         widgets={
@@ -206,14 +226,15 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
   const allTopics = allTopicsEdges.edges.map((edge) =>
     getPageLinkFromRawData(edge.node)
   );
+  const topicsLinks = allTopics.filter(
+    (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
+  );
   const translation = await loadTranslation(locale);
 
   return {
     props: {
       currentTopic: JSON.parse(JSON.stringify(currentTopic)),
-      topics: allTopics.filter(
-        (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
-      ),
+      topics: JSON.parse(JSON.stringify(topicsLinks)),
       translation,
     },
   };
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index d65bc9e..e02ca8e 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -14,6 +14,17 @@ export const articleBySlugQuery = `query PostBy($slug: ID!) {
       postsInTopic {
         ... on Topic {
           databaseId
+          featuredImage {
+            node {
+              altText
+              mediaDetails {
+                height
+                width
+              }
+              sourceUrl
+              title
+            }
+          }
           slug
           title
         }
diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts
index 19be5d7..4574256 100644
--- a/src/services/graphql/topics.query.ts
+++ b/src/services/graphql/topics.query.ts
@@ -94,6 +94,17 @@ export const topicsListQuery = `query TopicsList($after: String = "", $first: In
       cursor
       node {
         databaseId
+        featuredImage {
+          node {
+            altText
+            mediaDetails {
+              height
+              width
+            }
+            sourceUrl
+            title
+          }
+        }
         slug
         title
       }
diff --git a/src/styles/base/_typography.scss b/src/styles/base/_typography.scss
index 7b7a695..2c3c8cc 100644
--- a/src/styles/base/_typography.scss
+++ b/src/styles/base/_typography.scss
@@ -120,21 +120,15 @@ dl {
   & & {
     margin: var(--spacing-2xs) 0 0;
   }
-}
-
-dt {
-  flex: 0 0 max-content;
-  font-weight: 600;
-}
 
-dd {
-  flex: 0 0 auto;
-  margin: 0;
+  ::marker {
+    color: var(--color-primary-dark);
+  }
 }
 
 a {
   background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%)
-    0 0 / 100% 200% no-repeat;
+    0 0 / 100% 201% no-repeat;
   color: var(--color-primary);
   text-decoration-thickness: 0.15em;
   text-underline-offset: 20%;
@@ -199,13 +193,3 @@ pre {
   word-break: normal;
   word-wrap: normal;
 }
-
-figure {
-  margin: var(--spacing-md) 0;
-}
-
-figcaption {
-  margin-top: var(--spacing-xs);
-  font-size: var(--font-size-sm);
-  text-align: center;
-}
diff --git a/src/styles/components/_wp-blocks.scss b/src/styles/components/_wp-blocks.scss
deleted file mode 100644
index efd6db5..0000000
--- a/src/styles/components/_wp-blocks.scss
+++ /dev/null
@@ -1,166 +0,0 @@
-@use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/mixins" as mix;
-@use "@styles/abstracts/placeholders";
-
-.wp-block-quote {
-  margin: var(--spacing-sm) 0;
-  padding: var(--spacing-sm);
-  position: relative;
-  border: fun.convert-px(1) solid var(--color-primary-lighter);
-  border-left: fun.convert-px(5) solid var(--color-primary-lighter);
-  box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-    fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-      var(--color-shadow-light),
-    fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-      var(--color-shadow-light);
-  font-style: italic;
-
-  > *:last-child {
-    margin: 0;
-  }
-
-  cite {
-    font-size: var(--font-size-sm);
-    font-style: normal;
-    font-weight: 600;
-  }
-}
-
-.wp-block-code,
-.wp-block-preformatted {
-  margin: 0 auto var(--spacing-md);
-  padding: var(--spacing-xs) var(--spacing-sm);
-  background: var(--color-bg-secondary);
-  border: fun.convert-px(1) solid var(--color-border-light);
-  color: var(--color-fg);
-}
-
-.wp-block-columns {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr);
-  gap: var(--spacing-md);
-  margin: var(--spacing-md) 0;
-
-  @include mix.media("screen") {
-    @include mix.dimensions("sm") {
-      grid-template-columns: repeat(2, minmax(0, 1fr));
-    }
-  }
-
-  &.are-vertically-aligned-center {
-    align-items: center;
-  }
-}
-
-.wp-block-column {
-  > *:first-child {
-    margin-top: 0;
-  }
-
-  > *:last-child {
-    margin-bottom: 0;
-  }
-}
-
-.wp-block-gallery {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr);
-  gap: var(--spacing-sm);
-
-  .blocks-gallery-grid {
-    @extend %reset-list;
-
-    grid-column: 1 / -1;
-    grid-row: 1 / -1;
-    display: grid;
-    grid-template-columns: minmax(0, 1fr);
-    gap: var(--spacing-sm);
-  }
-
-  .blocks-gallery-item {
-    figure {
-      margin: 0;
-    }
-
-    a {
-      display: block;
-      box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-        fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-          var(--color-shadow-light),
-        fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-          var(--color-shadow-light);
-
-      &:hover,
-      &:focus {
-        transform: scale(1.05);
-        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-          fun.convert-px(3) fun.convert-px(3) fun.convert-px(2) 0
-            var(--color-shadow-light),
-          fun.convert-px(5) fun.convert-px(5) fun.convert-px(8) 0
-            var(--color-shadow-light);
-      }
-
-      &:focus {
-        outline: solid var(--color-primary-light);
-      }
-
-      &:active {
-        transform: scale(0.95);
-        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-          fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-            var(--color-shadow-light),
-          0 0 0 0 var(--color-shadow-light);
-        outline: none;
-      }
-    }
-  }
-
-  &.aligncenter {
-    .blocks-gallery-grid {
-      align-items: center;
-    }
-  }
-
-  @for $i from 0 to 6 {
-    &.columns-#{$i} {
-      @include mix.media("screen") {
-        @include mix.dimensions("xs") {
-          grid-template-columns: repeat(2, minmax(0, 1fr));
-
-          .blocks-gallery-grid {
-            grid-template-columns: repeat(2, minmax(0, 1fr));
-          }
-        }
-
-        @include mix.dimensions("sm") {
-          grid-template-columns: repeat(#{$i}, minmax(0, 1fr));
-
-          .blocks-gallery-grid {
-            grid-template-columns: repeat(3, minmax(0, 1fr));
-          }
-        }
-      }
-    }
-  }
-}
-
-.wp-block-image {
-  img {
-    display: block;
-    margin: auto;
-    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-        var(--color-shadow-light),
-      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-        var(--color-shadow-light);
-    text-align: center;
-  }
-}
-
-.wp-block-video {
-  box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-    fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-      var(--color-shadow-light),
-    fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-      var(--color-shadow-light);
-}
diff --git a/src/styles/globals.scss b/src/styles/globals.scss
index f9a1281..8ece909 100644
--- a/src/styles/globals.scss
+++ b/src/styles/globals.scss
@@ -6,7 +6,6 @@
  * Import each files separately to define vendors styles order.
  */
 @use "modern-normalize";
-@use "vendors/prism";
 
 /**
  * 2.0. Base
@@ -22,14 +21,7 @@
 @use "base/typography";
 
 /**
- * 3.0. Components
- *
- * Define styles for external components (like WordPress blocks).
- */
-@use "components/wp-blocks";
-
-/**
- * 4.0. Themes
+ * 3.0. Themes
  *
  * Define themes specific styles.
  */
diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss
new file mode 100644
index 0000000..a42c633
--- /dev/null
+++ b/src/styles/pages/article.module.scss
@@ -0,0 +1,36 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/variables" as var;
+@use "partials/article-links";
+@use "partials/article-lists";
+@use "partials/article-media";
+@use "partials/article-prism";
+@use "partials/article-wp-blocks";
+
+.btn {
+  margin-right: var(--spacing-2xs);
+  padding: var(--spacing-2xs) var(--spacing-xs);
+
+  &__icon {
+    max-width: fun.convert-px(22);
+    margin-right: var(--spacing-2xs);
+  }
+}
+
+.body {
+  :global {
+    @include article-links.styles;
+    @include article-lists.styles;
+    @include article-media.styles;
+    @include article-prism.styles;
+    @include article-wp-blocks.styles;
+  }
+}
+
+.widget {
+  @include mix.media("screen") {
+    @include mix.dimensions("md") {
+      width: min-content;
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-headings.scss b/src/styles/pages/partials/_article-headings.scss
new file mode 100644
index 0000000..c0c3519
--- /dev/null
+++ b/src/styles/pages/partials/_article-headings.scss
@@ -0,0 +1,57 @@
+@use "@styles/abstracts/functions" as fun;
+
+@mixin styles {
+  h1 {
+    font-size: var(--font-size-3xl);
+    font-weight: 500;
+  }
+
+  h2 {
+    padding-bottom: fun.convert-px(3);
+    background: linear-gradient(
+        to top,
+        var(--color-primary-dark) 0.3rem,
+        transparent 0.3rem
+      )
+      0 0 / 3rem 100% no-repeat;
+    font-size: var(--font-size-2xl);
+    font-weight: 500;
+    text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light);
+  }
+
+  h3 {
+    font-size: var(--font-size-xl);
+    font-weight: 500;
+  }
+
+  h4 {
+    font-size: var(--font-size-lg);
+    font-weight: 500;
+  }
+
+  h5 {
+    font-size: var(--font-size-md);
+    font-weight: 600;
+  }
+
+  h6 {
+    font-size: var(--font-size-md);
+    font-weight: 500;
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    color: var(--color-primary-dark);
+    font-family: var(--font-family-secondary);
+    letter-spacing: 0.01ex;
+    margin: 0 0 var(--spacing-sm);
+
+    & + & {
+      margin-top: var(--spacing-md);
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-links.scss b/src/styles/pages/partials/_article-links.scss
new file mode 100644
index 0000000..df86dcf
--- /dev/null
+++ b/src/styles/pages/partials/_article-links.scss
@@ -0,0 +1,104 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/variables" as var;
+
+@mixin styles {
+  a {
+    &[hreflang] {
+      &::after {
+        display: inline-block;
+
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]";
+        font-size: var(--font-size-sm);
+      }
+    }
+  }
+
+  a.download {
+    &::after {
+      display: inline-block;
+
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+    }
+
+    &:focus:not(:active)::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+      }
+    }
+  }
+
+  a.external {
+    &::after {
+      display: inline-block;
+
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &:focus:not(:active)::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+
+      &:focus:not(:active)::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+    }
+  }
+
+  a.external.download {
+    &::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+
+      &:focus:not(:active)::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-lists.scss b/src/styles/pages/partials/_article-lists.scss
new file mode 100644
index 0000000..c0084b0
--- /dev/null
+++ b/src/styles/pages/partials/_article-lists.scss
@@ -0,0 +1,65 @@
+@mixin styles {
+  ol {
+    padding: 0;
+    list-style-type: none;
+    counter-reset: li;
+
+    > li {
+      display: table;
+      counter-increment: li;
+
+      &::before {
+        content: counters(li, ".") ". ";
+        display: table-cell;
+        padding-right: var(--spacing-2xs);
+        color: var(--color-secondary);
+      }
+    }
+
+    li ol > li::before {
+      content: counters(li, ".") ". ";
+    }
+  }
+
+  ul,
+  ol {
+    li:not(:last-child) {
+      margin-bottom: var(--spacing-2xs);
+    }
+
+    ::marker {
+      color: var(--color-primary-dark);
+    }
+  }
+
+  ul {
+    padding-left: var(--spacing-sm);
+  }
+
+  dl {
+    display: flex;
+    flex-flow: row wrap;
+    gap: var(--spacing-2xs);
+    width: fit-content;
+  }
+
+  ul,
+  ol,
+  dl {
+    margin: var(--spacing-sm) 0;
+
+    & & {
+      margin: var(--spacing-2xs) 0 0;
+    }
+  }
+
+  dt {
+    color: var(--color-fg-light);
+    font-weight: 600;
+  }
+
+  dd {
+    margin: 0;
+    word-break: break-all;
+  }
+}
diff --git a/src/styles/pages/partials/_article-media.scss b/src/styles/pages/partials/_article-media.scss
new file mode 100644
index 0000000..8359881
--- /dev/null
+++ b/src/styles/pages/partials/_article-media.scss
@@ -0,0 +1,11 @@
+@mixin styles {
+  figure {
+    margin: var(--spacing-md) 0;
+  }
+
+  figcaption {
+    margin-top: var(--spacing-xs);
+    font-size: var(--font-size-sm);
+    text-align: center;
+  }
+}
diff --git a/src/styles/pages/partials/_article-prism.scss b/src/styles/pages/partials/_article-prism.scss
new file mode 100644
index 0000000..025e0c0
--- /dev/null
+++ b/src/styles/pages/partials/_article-prism.scss
@@ -0,0 +1,301 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+@mixin styles {
+  .code-toolbar {
+    --gutter-size: clamp(#{fun.convert-px(75)}, 20vw, #{fun.convert-px(90)});
+    --toolbar-height: #{fun.convert-px(90)};
+
+    position: relative;
+    margin-top: calc(var(--toolbar-height) + var(--spacing-md));
+
+    @include mix.media("screen") {
+      @include mix.dimensions("2xs") {
+        --toolbar-height: #{fun.convert-px(60)};
+      }
+    }
+
+    .toolbar {
+      display: grid;
+      grid-template-columns: max-content minmax(0, 1fr);
+      justify-items: end;
+      width: 100%;
+      height: var(--toolbar-height);
+      position: absolute;
+      top: calc(var(--toolbar-height) * -1);
+      left: 0;
+      right: 0;
+      background: var(--color-bg-tertiary);
+      border: fun.convert-px(1) solid var(--color-border);
+
+      @include mix.media("screen") {
+        @include mix.dimensions("2xs") {
+          display: flex;
+          flex-flow: row wrap;
+        }
+      }
+    }
+
+    .toolbar-item {
+      display: flex;
+      align-items: center;
+    }
+
+    .toolbar-item:nth-child(1) {
+      grid-column: 1;
+      grid-row: 1 / 3;
+      margin-right: auto;
+      padding: 0 var(--spacing-sm);
+      background: var(--color-bg-code);
+      border-right: fun.convert-px(1) solid var(--color-border);
+      color: var(--color-primary-darker);
+      font-size: var(--font-size-sm);
+      font-weight: 600;
+    }
+
+    .toolbar-item:nth-child(2) {
+      grid-column: 2;
+      grid-row: 1;
+      margin: 0 var(--spacing-2xs);
+    }
+
+    .toolbar-item:nth-child(3) {
+      grid-column: 2;
+      grid-row: 2;
+      margin: 0 var(--spacing-2xs);
+    }
+  }
+
+  pre[class*="language-"] {
+    max-height: max(30vw, fun.convert-px(300));
+    margin: var(--spacing-md) 0;
+    padding: 0;
+    position: relative;
+    background: var(--color-bg-secondary);
+    color: var(--color-fg);
+    border: fun.convert-px(1) solid var(--color-border);
+
+    > code {
+      display: block;
+      padding: var(--spacing-xs) 0 var(--spacing-xs)
+        calc(var(--gutter-size) + var(--spacing-xs));
+    }
+
+    .line-numbers-rows,
+    .command-line-prompt {
+      width: var(--gutter-size);
+      min-height: 100%;
+      padding: var(--spacing-xs) var(--spacing-2xs);
+      position: absolute;
+      top: 0;
+      left: 0;
+      pointer-events: none;
+      user-select: none;
+      background: var(--color-bg);
+      border-right: fun.convert-px(1) solid var(--color-border);
+    }
+
+    .token {
+      &.comment,
+      &.doc-comment {
+        color: var(--color-fg-light);
+      }
+
+      &.punctuation {
+        color: var(--color-fg);
+      }
+
+      &.attr-name,
+      &.hexcode,
+      &.inserted,
+      &.string {
+        color: var(--color-token-green);
+      }
+
+      &.class,
+      &.coord,
+      &.id,
+      &.function {
+        color: var(--color-token-purple);
+      }
+
+      &.builtin,
+      &.builtin.class-name,
+      &.property-access,
+      &.regex,
+      &.scope {
+        color: var(--color-token-magenta);
+      }
+
+      &.class-name,
+      &.constant,
+      &.global,
+      &.interpolation,
+      &.key,
+      &.package,
+      &.this,
+      &.title,
+      &.variable {
+        color: var(--color-token-blue);
+      }
+
+      &.combinator,
+      &.keyword,
+      &.operator,
+      &.pseudo-class,
+      &.pseudo-element,
+      &.rule,
+      &.selector,
+      &.unit {
+        color: var(--color-token-orange);
+      }
+
+      &.attr-value,
+      &.boolean,
+      &.number {
+        color: var(--color-token-yellow);
+      }
+
+      &.delimiter,
+      &.doctype,
+      &.parameter,
+      &.parent,
+      &.property,
+      &.shebang,
+      &.tag {
+        color: var(--color-token-cyan);
+      }
+
+      &.deleted {
+        color: var(--color-token-red);
+      }
+
+      &.punctuation.brace-hover,
+      &.punctuation.brace-selected {
+        background: var(--color-bg);
+        outline: solid fun.convert-px(1) var(--color-primary-light);
+      }
+    }
+
+    span.inline-color-wrapper {
+      background: url(fun.encode-svg(
+        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path fill="gray" d="M0 0h2v2H0z"/><path fill="white" d="M0 0h1v1H0zM1 1h1v1H1z"/></svg>'
+      ));
+
+      /* Prevent glitches where 1px from the repeating pattern could be seen. */
+      background-position: center;
+      background-size: 110%;
+
+      display: inline-block;
+      height: 1.1ch;
+      width: 1.1ch;
+      margin: 0 0.5ch 0 0;
+      border: fun.convert-px(1) solid var(--color-bg);
+      outline: fun.convert-px(1) solid var(--color-border-dark);
+      overflow: hidden;
+    }
+
+    span.inline-color {
+      display: block;
+
+      /* To prevent visual glitches again */
+      height: 120%;
+      width: 120%;
+    }
+  }
+
+  pre.line-numbers {
+    counter-reset: lineNumber;
+
+    .line-numbers-rows {
+      > span {
+        counter-increment: lineNumber;
+
+        &::before {
+          display: block;
+          padding: 0 var(--spacing-xs);
+          content: counter(lineNumber);
+          color: var(--color-primary-darker);
+          text-align: right;
+          line-height: var(--line-height);
+        }
+      }
+    }
+  }
+
+  pre.command-line {
+    --gutter-size: clamp(#{fun.convert-px(195)}, 48vw, #{fun.convert-px(235)});
+
+    ~ .toolbar {
+      --gutter-size: clamp(
+        #{fun.convert-px(195)},
+        48vw,
+        #{fun.convert-px(235)}
+      );
+    }
+
+    .command-line-prompt {
+      > span {
+        &::before {
+          display: block;
+          content: "";
+        }
+
+        &[data-user]::before {
+          content: "[" attr(data-user) "@" attr(data-host) "] $";
+        }
+
+        &[data-user="root"]::before {
+          content: "[" attr(data-user) "@" attr(data-host) "] #";
+        }
+
+        &[data-prompt]::before {
+          content: attr(data-prompt);
+        }
+      }
+    }
+  }
+
+  .copy-to-clipboard-button,
+  .prism-color-scheme-button {
+    display: block;
+    padding: fun.convert-px(3) var(--spacing-xs);
+    background: var(--color-bg);
+    border: 0.4ex solid var(--color-primary);
+    border-radius: fun.convert-px(30);
+    box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+        var(--color-shadow),
+      fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+        var(--color-shadow),
+      fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+        var(--color-shadow);
+    color: var(--color-primary);
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+    transition: all 0.35s ease-in-out 0s;
+
+    &:hover,
+    &:focus {
+      transform: translateX(#{fun.convert-px(-2)})
+        translateY(#{fun.convert-px(-2)});
+      box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+          var(--color-shadow-light),
+        fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+          var(--color-shadow-light),
+        fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+          var(--color-shadow-light),
+        fun.convert-px(4) fun.convert-px(7) fun.convert-px(8) fun.convert-px(-3)
+          var(--color-shadow-light);
+    }
+
+    &:focus {
+      text-decoration: underline var(--color-primary) fun.convert-px(3);
+    }
+
+    &:active {
+      text-decoration: none;
+      transform: translateY(#{fun.convert-px(2)});
+      box-shadow: 0 0 0 0 var(--color-shadow);
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-wp-blocks.scss b/src/styles/pages/partials/_article-wp-blocks.scss
new file mode 100644
index 0000000..d4fed5a
--- /dev/null
+++ b/src/styles/pages/partials/_article-wp-blocks.scss
@@ -0,0 +1,168 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+@mixin styles {
+  .wp-block-quote {
+    margin: var(--spacing-sm) 0;
+    padding: var(--spacing-sm);
+    position: relative;
+    border: fun.convert-px(1) solid var(--color-primary-lighter);
+    border-left: fun.convert-px(5) solid var(--color-primary-lighter);
+    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+        var(--color-shadow-light),
+      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+        var(--color-shadow-light);
+    font-style: italic;
+
+    > *:last-child {
+      margin: 0;
+    }
+
+    cite {
+      font-size: var(--font-size-sm);
+      font-style: normal;
+      font-weight: 600;
+    }
+  }
+
+  .wp-block-code,
+  .wp-block-preformatted {
+    margin: 0 auto var(--spacing-md);
+    padding: var(--spacing-xs) var(--spacing-sm);
+    background: var(--color-bg-secondary);
+    border: fun.convert-px(1) solid var(--color-border-light);
+    color: var(--color-fg);
+  }
+
+  .wp-block-columns {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    gap: var(--spacing-md);
+    margin: var(--spacing-md) 0;
+
+    @include mix.media("screen") {
+      @include mix.dimensions("sm") {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+      }
+    }
+
+    &.are-vertically-aligned-center {
+      align-items: center;
+    }
+  }
+
+  .wp-block-column {
+    > *:first-child {
+      margin-top: 0;
+    }
+
+    > *:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .wp-block-gallery {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    gap: var(--spacing-sm);
+
+    .blocks-gallery-grid {
+      @extend %reset-list;
+
+      grid-column: 1 / -1;
+      grid-row: 1 / -1;
+      display: grid;
+      grid-template-columns: minmax(0, 1fr);
+      gap: var(--spacing-sm);
+    }
+
+    .blocks-gallery-item {
+      figure {
+        margin: 0;
+      }
+
+      a {
+        display: block;
+        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+          fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+            var(--color-shadow-light),
+          fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+            var(--color-shadow-light);
+
+        &:hover,
+        &:focus {
+          transform: scale(1.05);
+          box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+            fun.convert-px(3) fun.convert-px(3) fun.convert-px(2) 0
+              var(--color-shadow-light),
+            fun.convert-px(5) fun.convert-px(5) fun.convert-px(8) 0
+              var(--color-shadow-light);
+        }
+
+        &:focus {
+          outline: solid var(--color-primary-light);
+        }
+
+        &:active {
+          transform: scale(0.95);
+          box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+            fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+              var(--color-shadow-light),
+            0 0 0 0 var(--color-shadow-light);
+          outline: none;
+        }
+      }
+    }
+
+    &.aligncenter {
+      .blocks-gallery-grid {
+        align-items: center;
+      }
+    }
+
+    @for $i from 0 to 6 {
+      &.columns-#{$i} {
+        @include mix.media("screen") {
+          @include mix.dimensions("xs") {
+            grid-template-columns: repeat(2, minmax(0, 1fr));
+
+            .blocks-gallery-grid {
+              grid-template-columns: repeat(2, minmax(0, 1fr));
+            }
+          }
+
+          @include mix.dimensions("sm") {
+            grid-template-columns: repeat(#{$i}, minmax(0, 1fr));
+
+            .blocks-gallery-grid {
+              grid-template-columns: repeat(3, minmax(0, 1fr));
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .wp-block-image {
+    img {
+      display: block;
+      margin: auto;
+      box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+        fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+          var(--color-shadow-light),
+        fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+          var(--color-shadow-light);
+      text-align: center;
+    }
+  }
+
+  .wp-block-video {
+    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+        var(--color-shadow-light),
+      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+        var(--color-shadow-light);
+  }
+}
diff --git a/src/styles/pages/topic.module.scss b/src/styles/pages/topic.module.scss
new file mode 100644
index 0000000..badb694
--- /dev/null
+++ b/src/styles/pages/topic.module.scss
@@ -0,0 +1,6 @@
+@use "@styles/abstracts/functions" as fun;
+
+.logo {
+  max-width: fun.convert-px(50);
+  margin-right: var(--spacing-xs);
+}
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index f354118..a3b9889 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -81,6 +81,7 @@ export type Page<T extends PageKind> = {
 
 export type PageLink = {
   id: number;
+  logo?: Image;
   name: string;
   slug: string;
 };
diff --git a/src/ts/types/raw-data.ts b/src/ts/types/raw-data.ts
index 7e12e7f..ba6d596 100644
--- a/src/ts/types/raw-data.ts
+++ b/src/ts/types/raw-data.ts
@@ -89,14 +89,17 @@ export type RawThematic = RawPage & {
 
 export type RawThematicPreview = Pick<
   RawThematic,
-  'databaseId' | 'slug' | 'title'
+  'databaseId' | 'featuredImage' | 'slug' | 'title'
 >;
 
 export type RawTopic = RawPage & {
   acfTopics: ACFTopics;
 };
 
-export type RawTopicPreview = Pick<RawTopic, 'databaseId' | 'slug' | 'title'>;
+export type RawTopicPreview = Pick<
+  RawTopic,
+  'databaseId' | 'featuredImage' | 'slug' | 'title'
+>;
 
 export type TotalItems = {
   pageInfo: Pick<PageInfo, 'total'>;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 93582f0..62337db 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -5,12 +5,14 @@ import {
   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).
@@ -18,10 +20,11 @@ import {
 export const getPageLinkFromRawData = (
   data: RawThematicPreview | RawTopicPreview
 ): PageLink => {
-  const { databaseId, slug, title } = data;
+  const { databaseId, featuredImage, slug, title } = data;
 
   return {
     id: databaseId,
+    logo: featuredImage ? getImageFromRawData(featuredImage?.node) : undefined,
     name: title,
     slug,
   };
diff --git a/src/utils/hooks/use-add-prism-class-attr.tsx b/src/utils/hooks/use-add-prism-class-attr.tsx
new file mode 100644
index 0000000..7d33cc2
--- /dev/null
+++ b/src/utils/hooks/use-add-prism-class-attr.tsx
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type AttributesMap = {
+  [key: string]: string;
+};
+
+export type useAddPrismClassAttrProps = {
+  attributes?: AttributesMap;
+  classNames?: string;
+};
+
+/**
+ * Add classnames and/or attributes to pre elements.
+ *
+ * @param props - An object of attributes and classnames.
+ */
+const useAddPrismClassAttr = ({
+  attributes,
+  classNames,
+}: useAddPrismClassAttrProps) => {
+  const [elements, setElements] = useState<HTMLPreElement[]>([]);
+
+  useEffect(() => {
+    const targetElements = document.querySelectorAll('pre');
+    setElements(Array.from(targetElements));
+  }, []);
+
+  const setClassNameAndAttributes = useCallback(
+    (array: HTMLElement[]) => {
+      array.forEach((el) => {
+        if (classNames) {
+          const classNamesArray = classNames.split(' ');
+          const isCommandLine = el.classList.contains('command-line');
+          const removedClassName = isCommandLine
+            ? 'line-numbers'
+            : 'command-line';
+          const filteredClassNames = classNamesArray.filter(
+            (className) => className !== removedClassName
+          );
+          filteredClassNames.forEach((className) =>
+            el.classList.add(className)
+          );
+        }
+
+        if (attributes) {
+          for (const [key, value] of Object.entries(attributes)) {
+            el.setAttribute(key, value);
+          }
+        }
+      });
+    },
+    [attributes, classNames]
+  );
+
+  useEffect(() => {
+    if (elements.length > 0) setClassNameAndAttributes(elements);
+  }, [elements, setClassNameAndAttributes]);
+};
+
+export default useAddPrismClassAttr;
-- 
cgit v1.2.3


From 7d9f874364ec6e255e62eb3027011bfed267904c Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 17 May 2022 18:37:38 +0200
Subject: chore: adjust articles styles

* change animation on article card hover
* change comments section alignment
---
 src/components/atoms/buttons/buttons.module.scss   |  2 +-
 src/components/atoms/headings/heading.module.scss  | 16 +++-
 src/components/atoms/headings/heading.stories.tsx  | 26 +++++++
 src/components/atoms/headings/heading.tsx          | 58 ++++++++++++---
 .../molecules/buttons/heading-button.module.scss   | 15 ++--
 .../molecules/buttons/heading-button.stories.tsx   | 73 ++++++-------------
 src/components/organisms/forms/comment-form.tsx    | 14 +++-
 .../organisms/layout/summary.module.scss           |  7 +-
 .../templates/page/page-layout.module.scss         |  9 ++-
 src/components/templates/page/page-layout.tsx      | 85 ++++++++++++++--------
 src/pages/article/[slug].tsx                       | 18 ++++-
 src/styles/pages/article.module.scss               |  1 +
 12 files changed, 211 insertions(+), 113 deletions(-)

(limited to 'src/components/organisms/forms/comment-form.tsx')

diff --git a/src/components/atoms/buttons/buttons.module.scss b/src/components/atoms/buttons/buttons.module.scss
index 36c66b6..2444bb1 100644
--- a/src/components/atoms/buttons/buttons.module.scss
+++ b/src/components/atoms/buttons/buttons.module.scss
@@ -16,7 +16,7 @@
   }
 
   &--rectangle {
-    padding: var(--spacing-2xs) var(--spacing-md);
+    padding: var(--spacing-2xs) var(--spacing-sm);
   }
 
   &--square,
diff --git a/src/components/atoms/headings/heading.module.scss b/src/components/atoms/headings/heading.module.scss
index 8620f6f..a420bc1 100644
--- a/src/components/atoms/headings/heading.module.scss
+++ b/src/components/atoms/headings/heading.module.scss
@@ -6,11 +6,23 @@
   letter-spacing: 0.01ex;
 
   &--regular {
-    margin: 0;
+    margin-bottom: 0;
+    margin-top: 0;
+  }
+
+  &--left {
+    text-align: left;
+  }
+
+  &--center {
+    width: fit-content;
+    margin-left: auto;
+    margin-right: auto;
   }
 
   &--margin {
-    margin: 0 0 var(--spacing-sm);
+    margin-top: 0;
+    margin-bottom: var(--spacing-sm);
 
     & + & {
       margin-top: var(--spacing-md);
diff --git a/src/components/atoms/headings/heading.stories.tsx b/src/components/atoms/headings/heading.stories.tsx
index da5a718..0e3885d 100644
--- a/src/components/atoms/headings/heading.stories.tsx
+++ b/src/components/atoms/headings/heading.stories.tsx
@@ -8,10 +8,26 @@ export default {
   title: 'Atoms/Typography/Headings',
   component: Heading,
   args: {
+    alignment: 'left',
     isFake: false,
     withMargin: true,
   },
   argTypes: {
+    alignment: {
+      control: {
+        type: 'select',
+      },
+      description: 'The title alignment.',
+      options: ['center', 'left'],
+      table: {
+        category: 'Options',
+        defaultValue: { summary: 'left' },
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -32,6 +48,16 @@ export default {
         required: true,
       },
     },
+    id: {
+      control: {
+        type: 'text',
+      },
+      description: 'An unique id.',
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     isFake: {
       control: {
         type: 'boolean',
diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx
index c5bf4ca..e385249 100644
--- a/src/components/atoms/headings/heading.tsx
+++ b/src/components/atoms/headings/heading.tsx
@@ -1,9 +1,19 @@
-import { FC, ReactNode } from 'react';
+import {
+  createElement,
+  ForwardedRef,
+  forwardRef,
+  ForwardRefRenderFunction,
+  ReactNode,
+} from 'react';
 import styles from './heading.module.scss';
 
 export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
 
 export type HeadingProps = {
+  /**
+   * The title alignment. Default: left;
+   */
+  alignment?: 'center' | 'left';
   /**
    * The heading body.
    */
@@ -30,31 +40,55 @@ export type HeadingProps = {
   withMargin?: boolean;
 };
 
+type TitleTagProps = Pick<HeadingProps, 'children' | 'className' | 'id'> & {
+  tagName: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p';
+};
+
+const TitleTag = forwardRef<
+  HTMLHeadingElement | HTMLParagraphElement,
+  TitleTagProps
+>(
+  (
+    { children, tagName, ...props },
+    ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement>
+  ) => {
+    return createElement(tagName, { ...props, ref }, children);
+  }
+);
+TitleTag.displayName = 'TitleTag';
+
 /**
  * Heading component.
  *
  * Render an HTML heading element or a paragraph with heading styles.
  */
-const Heading: FC<HeadingProps> = ({
-  children,
-  className,
-  id,
-  isFake = false,
-  level,
-  withMargin = true,
-}) => {
-  const TitleTag = isFake ? `p` : (`h${level}` as keyof JSX.IntrinsicElements);
+const Heading: ForwardRefRenderFunction<HTMLDivElement, HeadingProps> = (
+  {
+    alignment = 'left',
+    children,
+    className,
+    id,
+    isFake = false,
+    level,
+    withMargin = true,
+  },
+  ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement>
+) => {
+  const tagName = isFake ? 'p' : (`h${level}` as TitleTagProps['tagName']);
   const levelClass = `heading--${level}`;
+  const alignmentClass = `heading--${alignment}`;
   const marginClass = withMargin ? 'heading--margin' : 'heading--regular';
 
   return (
     <TitleTag
-      className={`${styles.heading} ${styles[levelClass]} ${styles[marginClass]} ${className}`}
+      tagName={tagName}
+      className={`${styles.heading} ${styles[levelClass]} ${styles[alignmentClass]} ${styles[marginClass]} ${className}`}
       id={id}
+      ref={ref}
     >
       {children}
     </TitleTag>
   );
 };
 
-export default Heading;
+export default forwardRef(Heading);
diff --git a/src/components/molecules/buttons/heading-button.module.scss b/src/components/molecules/buttons/heading-button.module.scss
index 9c278e4..3c69221 100644
--- a/src/components/molecules/buttons/heading-button.module.scss
+++ b/src/components/molecules/buttons/heading-button.module.scss
@@ -20,6 +20,14 @@
   border-bottom: fun.convert-px(2) solid var(--color-primary-dark);
   cursor: pointer;
 
+  .heading {
+    padding: var(--spacing-2xs) 0;
+    background: none;
+    font-size: var(--font-size-xl);
+    font-weight: 500;
+    text-align: left;
+  }
+
   &:hover,
   &:focus {
     .icon {
@@ -34,10 +42,3 @@
     }
   }
 }
-
-.heading {
-  padding: var(--spacing-2xs) 0;
-  background: none;
-  font-size: var(--font-size-xl);
-  text-align: left;
-}
diff --git a/src/components/molecules/buttons/heading-button.stories.tsx b/src/components/molecules/buttons/heading-button.stories.tsx
index b0e1465..d1b5ed4 100644
--- a/src/components/molecules/buttons/heading-button.stories.tsx
+++ b/src/components/molecules/buttons/heading-button.stories.tsx
@@ -1,6 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import { useState } from 'react';
-import { IntlProvider } from 'react-intl';
 import HeadingButtonComponent from './heading-button';
 
 /**
@@ -10,6 +9,19 @@ export default {
   title: 'Molecules/Buttons/HeadingButton',
   component: HeadingButtonComponent,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the button.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     expanded: {
       control: {
         type: null,
@@ -53,13 +65,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof HeadingButtonComponent>;
 
 const Template: ComponentStory<typeof HeadingButtonComponent> = ({
@@ -79,55 +84,21 @@ const Template: ComponentStory<typeof HeadingButtonComponent> = ({
 };
 
 /**
- * Heading Button Stories - Level 1
+ * Heading Button Stories - Expanded
  */
-export const Level1 = Template.bind({});
-Level1.args = {
-  level: 1,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 2
- */
-export const Level2 = Template.bind({});
-Level2.args = {
+export const Expanded = Template.bind({});
+Expanded.args = {
+  expanded: true,
   level: 2,
   title: 'Your title',
 };
 
 /**
- * Heading Button Stories - Level 3
+ * Heading Button Stories - Collapsed
  */
-export const Level3 = Template.bind({});
-Level3.args = {
-  level: 3,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 4
- */
-export const Level4 = Template.bind({});
-Level4.args = {
-  level: 4,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 5
- */
-export const Level5 = Template.bind({});
-Level5.args = {
-  level: 5,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 6
- */
-export const Level6 = Template.bind({});
-Level6.args = {
-  level: 6,
+export const Collapsed = Template.bind({});
+Collapsed.args = {
+  expanded: false,
+  level: 2,
   title: 'Your title',
 };
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index 5ff4ea4..b2c725f 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -1,6 +1,9 @@
 import Button from '@components/atoms/buttons/button';
 import Form, { type FormProps } from '@components/atoms/forms/form';
-import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Heading, {
+  type HeadingProps,
+  type HeadingLevel,
+} from '@components/atoms/headings/heading';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
 import { FC, ReactNode, useState } from 'react';
@@ -34,7 +37,11 @@ export type CommentFormProps = Pick<FormProps, 'className'> & {
    */
   title?: string;
   /**
-   * The title level.
+   * The form title alignment. Default: left.
+   */
+  titleAlignment?: HeadingProps['alignment'];
+  /**
+   * The title level. Default: 2.
    */
   titleLevel?: HeadingLevel;
 };
@@ -44,6 +51,7 @@ const CommentForm: FC<CommentFormProps> = ({
   parentId,
   saveComment,
   title,
+  titleAlignment,
   titleLevel = 2,
   ...props
 }) => {
@@ -117,7 +125,7 @@ const CommentForm: FC<CommentFormProps> = ({
       {...props}
     >
       {title && (
-        <Heading id={formId} level={titleLevel}>
+        <Heading id={formId} level={titleLevel} alignment={titleAlignment}>
           {title}
         </Heading>
       )}
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index 7e86dd2..62dfc0e 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -29,8 +29,11 @@
 
   &:hover {
     .icon {
-      transform: scaleX(1.4);
-      transform-origin: left;
+      --icon-size: #{fun.convert-px(35)};
+
+      :global {
+        animation: pulse 1.5s ease-in-out 0.2s infinite;
+      }
     }
   }
 }
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index 83e5a80..c6b4e8d 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -86,7 +86,14 @@
 
   &__section {
     grid-column: 2;
-    margin: var(--spacing-md) 0 0;
+
+    &:first-child {
+      margin: var(--spacing-md) 0 0;
+    }
+  }
+
+  &__no-comments {
+    text-align: center;
   }
 
   &__form {
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index f3f3ea8..2ff2084 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -1,6 +1,7 @@
 import Heading from '@components/atoms/headings/heading';
 import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import Sidebar from '@components/atoms/layout/sidebar';
+import { MetaData } from '@components/molecules/layout/meta';
 import PageFooter, {
   type PageFooterProps,
 } from '@components/molecules/layout/page-footer';
@@ -118,10 +119,10 @@ const PageLayout: FC<PageLayoutProps> = ({
   const isMounted = useIsMounted(bodyRef);
 
   const hasComments = Array.isArray(comments) && comments.length > 0;
-  const hasCommentsSection = hasComments || allowComments;
-  const articleModifier = hasCommentsSection
-    ? 'article--has-comments'
-    : 'article--no-comments';
+  const articleModifier =
+    hasComments || allowComments
+      ? 'article--has-comments'
+      : 'article--no-comments';
 
   const [status, setStatus] = useState<NoticeKind>('info');
   const [statusMessage, setStatusMessage] = useState<string>('');
@@ -175,6 +176,15 @@ const PageLayout: FC<PageLayoutProps> = ({
     }
   };
 
+  /**
+   * Check if meta properties are defined.
+   *
+   * @param {MetaData} meta - The metadata.
+   */
+  const hasMeta = (meta: MetaData) => {
+    return Object.values(meta).every((value) => value === null);
+  };
+
   return (
     <Layout
       breadcrumbSchema={breadcrumbSchema}
@@ -218,7 +228,9 @@ const PageLayout: FC<PageLayoutProps> = ({
           {children}
         </div>
       )}
-      <PageFooter meta={footerMeta} className={styles.footer} />
+      {footerMeta && hasMeta(footerMeta) && (
+        <PageFooter meta={footerMeta} className={styles.footer} />
+      )}
       <Sidebar
         className={`${styles.sidebar} ${styles['sidebar--last']}`}
         aria-label={intl.formatMessage({
@@ -229,45 +241,54 @@ const PageLayout: FC<PageLayoutProps> = ({
       >
         {widgets}
       </Sidebar>
-      {hasCommentsSection && (
-        <div className={styles.comments}>
-          {hasComments && (
-            <section className={styles.comments__section}>
-              <Heading level={2}>{commentsTitle}</Heading>
+      {allowComments && (
+        <div className={styles.comments} id="comments">
+          <section className={styles.comments__section}>
+            <Heading level={2} alignment="center">
+              {commentsTitle}
+            </Heading>
+            {hasComments ? (
               <CommentsList
                 comments={comments}
                 depth={1}
                 Notice={
-                  isReplyRef.current === true ? (
+                  isReplyRef.current === true && (
                     <Notice
                       kind={status}
                       message={statusMessage}
                       className={styles.notice}
                     />
-                  ) : undefined
+                  )
                 }
                 saveComment={saveComment}
               />
-            </section>
-          )}
-          {allowComments && (
-            <section className={styles.comments__section}>
-              <CommentForm
-                className={styles.comments__form}
-                saveComment={saveComment}
-                title={commentFormTitle}
-                Notice={
-                  isReplyRef.current === false ? (
-                    <Notice
-                      kind={status}
-                      message={statusMessage}
-                      className={styles.notice}
-                    />
-                  ) : undefined
-                }
-              />
-            </section>
-          )}
+            ) : (
+              <p className={styles['comments__no-comments']}>
+                {intl.formatMessage({
+                  defaultMessage: 'No comments.',
+                  id: 'sBwfCy',
+                  description: 'PageLayout: no comments text',
+                })}
+              </p>
+            )}
+          </section>
+          <section className={styles.comments__section}>
+            <CommentForm
+              className={styles.comments__form}
+              saveComment={saveComment}
+              title={commentFormTitle}
+              titleAlignment="center"
+              Notice={
+                isReplyRef.current === false && (
+                  <Notice
+                    kind={status}
+                    message={statusMessage}
+                    className={styles.notice}
+                  />
+                )
+              }
+            />
+          </section>
         </div>
       )}
     </Layout>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 995e3a9..a0fb7fc 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -15,7 +15,10 @@ import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
-import usePrismPlugins, { PrismPlugin } from '@utils/hooks/use-prism-plugins';
+import usePrismPlugins, {
+  type PrismPlugin,
+} from '@utils/hooks/use-prism-plugins';
+import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -38,7 +41,16 @@ type ArticlePageProps = {
  */
 const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   const { content, id, intro, meta, slug, title } = post;
-  const { author, commentsCount, cover, dates, seo, thematics, topics } = meta;
+  const {
+    author,
+    commentsCount,
+    cover,
+    dates,
+    seo,
+    thematics,
+    topics,
+    wordsCount,
+  } = meta;
   const { data } = useSWR(() => id, getPostComments, {
     fallbackData: comments,
   });
@@ -46,11 +58,13 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
     title,
     url: `/article/${slug}`,
   });
+  const readingTime = useReadingTime(wordsCount || 0, true);
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
     publication: { date: dates.publication },
     update: dates.update ? { date: dates.update } : undefined,
+    readingTime,
     thematics:
       thematics &&
       thematics.map((thematic) => (
diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss
index a42c633..04acad9 100644
--- a/src/styles/pages/article.module.scss
+++ b/src/styles/pages/article.module.scss
@@ -31,6 +31,7 @@
   @include mix.media("screen") {
     @include mix.dimensions("md") {
       width: min-content;
+      gap: var(--spacing-2xs);
     }
   }
 }
-- 
cgit v1.2.3

